fix(background-agent): defer unsafe parent wakes
This commit is contained in:
@@ -19,138 +19,6 @@ type PromptAsyncCall = {
|
|||||||
type ParentWakeClient = ConstructorParameters<typeof ParentWakeNotifier>[0]["client"]
|
type ParentWakeClient = ConstructorParameters<typeof ParentWakeNotifier>[0]["client"]
|
||||||
|
|
||||||
describe("ParentWakeNotifier — assistant turn blocking", () => {
|
describe("ParentWakeNotifier — assistant turn blocking", () => {
|
||||||
test("#given stale unfinished assistant text has no pending tool call #when checking parent wake history #then parent wake dispatches after defer max", async () => {
|
|
||||||
// given
|
|
||||||
const originalDateNow = Date.now
|
|
||||||
Date.now = () => 100_000
|
|
||||||
const client = unsafeTestValue<ParentWakeClient>({
|
|
||||||
session: {
|
|
||||||
messages: async () => ({
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
info: {
|
|
||||||
role: "assistant",
|
|
||||||
finish: "unknown",
|
|
||||||
time: { created: 90_000 },
|
|
||||||
},
|
|
||||||
parts: [{ type: "text", text: "still streaming" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
status: async () => ({ data: { "parent-stale-text": { type: "idle" } } }),
|
|
||||||
promptAsync: async () => {
|
|
||||||
return { data: {} }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const notifier = new ParentWakeNotifier(
|
|
||||||
{
|
|
||||||
client,
|
|
||||||
directory: "/tmp/test-omo",
|
|
||||||
enqueueNotificationForParent: async (_sessionID, operation) => {
|
|
||||||
await operation()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pendingRetryMs: 1_000,
|
|
||||||
acceptedMessageSkewMs: 5_000,
|
|
||||||
toolCallDeferMaxMs: 5_000,
|
|
||||||
failureRequeueWindowMs: 5_000,
|
|
||||||
userMessageInProgressWindowMs: 2_000,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
notifier.queuePendingParentWake(
|
|
||||||
"parent-stale-text",
|
|
||||||
"task complete",
|
|
||||||
{ agent: "sisyphus" },
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
const pendingWake = notifier.getPendingParentWakes().get("parent-stale-text")
|
|
||||||
expect(pendingWake).toBeDefined()
|
|
||||||
if (!pendingWake) {
|
|
||||||
throw new Error("Missing pending parent wake")
|
|
||||||
}
|
|
||||||
pendingWake.toolCallDeferralStartedAt = 90_000
|
|
||||||
|
|
||||||
try {
|
|
||||||
// when
|
|
||||||
const decision = await notifier["shouldDeferParentWakeForSessionHistory"]("parent-stale-text", pendingWake)
|
|
||||||
|
|
||||||
// then
|
|
||||||
expect(decision).toEqual({ defer: false, skipPromptGateToolStateCheck: false })
|
|
||||||
} finally {
|
|
||||||
Date.now = originalDateNow
|
|
||||||
notifier.shutdown()
|
|
||||||
releaseAllPromptAsyncReservationsForTesting()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("#given fresh unfinished assistant text has no pending tool call #when checking parent wake history #then parent wake continues deferring", async () => {
|
|
||||||
// given
|
|
||||||
const originalDateNow = Date.now
|
|
||||||
Date.now = () => 100_000
|
|
||||||
const client = unsafeTestValue<ParentWakeClient>({
|
|
||||||
session: {
|
|
||||||
messages: async () => ({
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
info: {
|
|
||||||
role: "assistant",
|
|
||||||
finish: "unknown",
|
|
||||||
time: { created: 99_000 },
|
|
||||||
},
|
|
||||||
parts: [{ type: "text", text: "still streaming" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
status: async () => ({ data: { "parent-fresh-text": { type: "idle" } } }),
|
|
||||||
promptAsync: async () => {
|
|
||||||
return { data: {} }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const notifier = new ParentWakeNotifier(
|
|
||||||
{
|
|
||||||
client,
|
|
||||||
directory: "/tmp/test-omo",
|
|
||||||
enqueueNotificationForParent: async (_sessionID, operation) => {
|
|
||||||
await operation()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pendingRetryMs: 1_000,
|
|
||||||
acceptedMessageSkewMs: 5_000,
|
|
||||||
toolCallDeferMaxMs: 5_000,
|
|
||||||
failureRequeueWindowMs: 5_000,
|
|
||||||
userMessageInProgressWindowMs: 2_000,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
notifier.queuePendingParentWake(
|
|
||||||
"parent-fresh-text",
|
|
||||||
"task complete",
|
|
||||||
{ agent: "sisyphus" },
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
const pendingWake = notifier.getPendingParentWakes().get("parent-fresh-text")
|
|
||||||
expect(pendingWake).toBeDefined()
|
|
||||||
if (!pendingWake) {
|
|
||||||
throw new Error("Missing pending parent wake")
|
|
||||||
}
|
|
||||||
pendingWake.toolCallDeferralStartedAt = 98_000
|
|
||||||
|
|
||||||
try {
|
|
||||||
// when
|
|
||||||
const decision = await notifier["shouldDeferParentWakeForSessionHistory"]("parent-fresh-text", pendingWake)
|
|
||||||
|
|
||||||
// then
|
|
||||||
expect(decision).toEqual({ defer: true, skipPromptGateToolStateCheck: false })
|
|
||||||
} finally {
|
|
||||||
Date.now = originalDateNow
|
|
||||||
notifier.shutdown()
|
|
||||||
releaseAllPromptAsyncReservationsForTesting()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("#given notifier sees an unfinished assistant but prompt gate message fetch fails #when flushing pending wake #then the wake stays pending", async () => {
|
test("#given notifier sees an unfinished assistant but prompt gate message fetch fails #when flushing pending wake #then the wake stays pending", async () => {
|
||||||
// given
|
// given
|
||||||
const promptAsyncCalls: PromptAsyncCall[] = []
|
const promptAsyncCalls: PromptAsyncCall[] = []
|
||||||
@@ -216,4 +84,86 @@ describe("ParentWakeNotifier — assistant turn blocking", () => {
|
|||||||
notifier.shutdown()
|
notifier.shutdown()
|
||||||
releaseAllPromptAsyncReservationsForTesting()
|
releaseAllPromptAsyncReservationsForTesting()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("#given stale completed assistant question tool has no real user answer #when flushing pending wake #then wake stays pending", async () => {
|
||||||
|
// given
|
||||||
|
const originalDateNow = Date.now
|
||||||
|
Date.now = () => 100_000
|
||||||
|
const promptAsyncCalls: PromptAsyncCall[] = []
|
||||||
|
const client = unsafeTestValue<ParentWakeClient>({
|
||||||
|
session: {
|
||||||
|
messages: async () => ({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
info: {
|
||||||
|
role: "user",
|
||||||
|
time: { created: 10_000 },
|
||||||
|
},
|
||||||
|
parts: [{ type: "text", text: "start work" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
info: {
|
||||||
|
role: "assistant",
|
||||||
|
finish: "tool-calls",
|
||||||
|
time: { created: 20_000, completed: 99_000 },
|
||||||
|
},
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: "tool",
|
||||||
|
tool: "question",
|
||||||
|
state: { status: "error" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
status: async () => ({ data: { "parent-question-unanswered": { type: "idle" } } }),
|
||||||
|
promptAsync: async (call: PromptAsyncCall) => {
|
||||||
|
promptAsyncCalls.push(call)
|
||||||
|
return { data: {} }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const notifier = new ParentWakeNotifier(
|
||||||
|
{
|
||||||
|
client,
|
||||||
|
directory: "/tmp/test-omo",
|
||||||
|
enqueueNotificationForParent: async (_sessionID, operation) => {
|
||||||
|
await operation()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pendingRetryMs: 1_000,
|
||||||
|
acceptedMessageSkewMs: 5_000,
|
||||||
|
toolCallDeferMaxMs: 5_000,
|
||||||
|
failureRequeueWindowMs: 5_000,
|
||||||
|
userMessageInProgressWindowMs: 2_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
notifier.queuePendingParentWake(
|
||||||
|
"parent-question-unanswered",
|
||||||
|
"task complete",
|
||||||
|
{ agent: "sisyphus" },
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
const pendingWake = notifier.getPendingParentWakes().get("parent-question-unanswered")
|
||||||
|
expect(pendingWake).toBeDefined()
|
||||||
|
if (!pendingWake) {
|
||||||
|
throw new Error("Missing pending parent wake")
|
||||||
|
}
|
||||||
|
pendingWake.toolCallDeferralStartedAt = 1_000
|
||||||
|
|
||||||
|
try {
|
||||||
|
// when
|
||||||
|
await notifier.flushPendingParentWake("parent-question-unanswered")
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(promptAsyncCalls).toHaveLength(0)
|
||||||
|
expect(notifier.getPendingParentWakes().has("parent-question-unanswered")).toBe(true)
|
||||||
|
} finally {
|
||||||
|
Date.now = originalDateNow
|
||||||
|
notifier.shutdown()
|
||||||
|
releaseAllPromptAsyncReservationsForTesting()
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { releaseAllPromptAsyncReservationsForTesting } from "../../hooks/shared/prompt-async-gate"
|
||||||
|
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||||
|
import { ParentWakeNotifier } from "./parent-wake-notifier"
|
||||||
|
|
||||||
|
type ParentWakeClient = ConstructorParameters<typeof ParentWakeNotifier>[0]["client"]
|
||||||
|
|
||||||
|
describe("ParentWakeNotifier — assistant history deferral", () => {
|
||||||
|
test("#given stale unfinished assistant text has no pending tool call #when checking parent wake history #then parent wake dispatches after defer max", async () => {
|
||||||
|
// given
|
||||||
|
const originalDateNow = Date.now
|
||||||
|
Date.now = () => 100_000
|
||||||
|
const client = unsafeTestValue<ParentWakeClient>({
|
||||||
|
session: {
|
||||||
|
messages: async () => ({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
info: {
|
||||||
|
role: "assistant",
|
||||||
|
finish: "unknown",
|
||||||
|
time: { created: 90_000 },
|
||||||
|
},
|
||||||
|
parts: [{ type: "text", text: "still streaming" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
status: async () => ({ data: { "parent-stale-text": { type: "idle" } } }),
|
||||||
|
promptAsync: async () => {
|
||||||
|
return { data: {} }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const notifier = new ParentWakeNotifier(
|
||||||
|
{
|
||||||
|
client,
|
||||||
|
directory: "/tmp/test-omo",
|
||||||
|
enqueueNotificationForParent: async (_sessionID, operation) => {
|
||||||
|
await operation()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pendingRetryMs: 1_000,
|
||||||
|
acceptedMessageSkewMs: 5_000,
|
||||||
|
toolCallDeferMaxMs: 5_000,
|
||||||
|
failureRequeueWindowMs: 5_000,
|
||||||
|
userMessageInProgressWindowMs: 2_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
notifier.queuePendingParentWake(
|
||||||
|
"parent-stale-text",
|
||||||
|
"task complete",
|
||||||
|
{ agent: "sisyphus" },
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
const pendingWake = notifier.getPendingParentWakes().get("parent-stale-text")
|
||||||
|
expect(pendingWake).toBeDefined()
|
||||||
|
if (!pendingWake) {
|
||||||
|
throw new Error("Missing pending parent wake")
|
||||||
|
}
|
||||||
|
pendingWake.toolCallDeferralStartedAt = 90_000
|
||||||
|
|
||||||
|
try {
|
||||||
|
// when
|
||||||
|
const decision = await notifier["shouldDeferParentWakeForSessionHistory"]("parent-stale-text", pendingWake)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(decision).toEqual({ defer: false, skipPromptGateToolStateCheck: false })
|
||||||
|
} finally {
|
||||||
|
Date.now = originalDateNow
|
||||||
|
notifier.shutdown()
|
||||||
|
releaseAllPromptAsyncReservationsForTesting()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given fresh unfinished assistant text has no pending tool call #when checking parent wake history #then parent wake continues deferring", async () => {
|
||||||
|
// given
|
||||||
|
const originalDateNow = Date.now
|
||||||
|
Date.now = () => 100_000
|
||||||
|
const client = unsafeTestValue<ParentWakeClient>({
|
||||||
|
session: {
|
||||||
|
messages: async () => ({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
info: {
|
||||||
|
role: "assistant",
|
||||||
|
finish: "unknown",
|
||||||
|
time: { created: 99_000 },
|
||||||
|
},
|
||||||
|
parts: [{ type: "text", text: "still streaming" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
status: async () => ({ data: { "parent-fresh-text": { type: "idle" } } }),
|
||||||
|
promptAsync: async () => {
|
||||||
|
return { data: {} }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const notifier = new ParentWakeNotifier(
|
||||||
|
{
|
||||||
|
client,
|
||||||
|
directory: "/tmp/test-omo",
|
||||||
|
enqueueNotificationForParent: async (_sessionID, operation) => {
|
||||||
|
await operation()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pendingRetryMs: 1_000,
|
||||||
|
acceptedMessageSkewMs: 5_000,
|
||||||
|
toolCallDeferMaxMs: 5_000,
|
||||||
|
failureRequeueWindowMs: 5_000,
|
||||||
|
userMessageInProgressWindowMs: 2_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
notifier.queuePendingParentWake(
|
||||||
|
"parent-fresh-text",
|
||||||
|
"task complete",
|
||||||
|
{ agent: "sisyphus" },
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
const pendingWake = notifier.getPendingParentWakes().get("parent-fresh-text")
|
||||||
|
expect(pendingWake).toBeDefined()
|
||||||
|
if (!pendingWake) {
|
||||||
|
throw new Error("Missing pending parent wake")
|
||||||
|
}
|
||||||
|
pendingWake.toolCallDeferralStartedAt = 98_000
|
||||||
|
|
||||||
|
try {
|
||||||
|
// when
|
||||||
|
const decision = await notifier["shouldDeferParentWakeForSessionHistory"]("parent-fresh-text", pendingWake)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(decision).toEqual({ defer: true, skipPromptGateToolStateCheck: false })
|
||||||
|
} finally {
|
||||||
|
Date.now = originalDateNow
|
||||||
|
notifier.shutdown()
|
||||||
|
releaseAllPromptAsyncReservationsForTesting()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given parent session messages cannot be inspected #when checking parent wake history #then parent wake stays deferred", async () => {
|
||||||
|
// given
|
||||||
|
const client = unsafeTestValue<ParentWakeClient>({
|
||||||
|
session: {
|
||||||
|
messages: async () => {
|
||||||
|
throw new Error("message endpoint failed")
|
||||||
|
},
|
||||||
|
status: async () => ({ data: { "parent-message-error": { type: "idle" } } }),
|
||||||
|
promptAsync: async () => {
|
||||||
|
return { data: {} }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const notifier = new ParentWakeNotifier(
|
||||||
|
{
|
||||||
|
client,
|
||||||
|
directory: "/tmp/test-omo",
|
||||||
|
enqueueNotificationForParent: async (_sessionID, operation) => {
|
||||||
|
await operation()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pendingRetryMs: 1_000,
|
||||||
|
acceptedMessageSkewMs: 5_000,
|
||||||
|
toolCallDeferMaxMs: 5_000,
|
||||||
|
failureRequeueWindowMs: 5_000,
|
||||||
|
userMessageInProgressWindowMs: 2_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
notifier.queuePendingParentWake(
|
||||||
|
"parent-message-error",
|
||||||
|
"task complete",
|
||||||
|
{ agent: "sisyphus" },
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
const pendingWake = notifier.getPendingParentWakes().get("parent-message-error")
|
||||||
|
expect(pendingWake).toBeDefined()
|
||||||
|
if (!pendingWake) {
|
||||||
|
throw new Error("Missing pending parent wake")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// when
|
||||||
|
const decision = await notifier["shouldDeferParentWakeForSessionHistory"]("parent-message-error", pendingWake)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(decision).toEqual({ defer: true, skipPromptGateToolStateCheck: false })
|
||||||
|
} finally {
|
||||||
|
notifier.shutdown()
|
||||||
|
releaseAllPromptAsyncReservationsForTesting()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given old assistant turn has recent running tool activity #when checking parent wake history #then stale tool escape stays deferred", async () => {
|
||||||
|
// given
|
||||||
|
const originalDateNow = Date.now
|
||||||
|
Date.now = () => 100_000
|
||||||
|
const client = unsafeTestValue<ParentWakeClient>({
|
||||||
|
session: {
|
||||||
|
messages: async () => ({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
info: {
|
||||||
|
role: "assistant",
|
||||||
|
finish: "tool-calls",
|
||||||
|
time: { created: 80_000 },
|
||||||
|
},
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: "tool",
|
||||||
|
tool: "bash",
|
||||||
|
time: { start: 99_000, end: 99_500 },
|
||||||
|
state: { status: "running" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
status: async () => ({ data: { "parent-fresh-tool-activity": { type: "idle" } } }),
|
||||||
|
promptAsync: async () => {
|
||||||
|
return { data: {} }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const notifier = new ParentWakeNotifier(
|
||||||
|
{
|
||||||
|
client,
|
||||||
|
directory: "/tmp/test-omo",
|
||||||
|
enqueueNotificationForParent: async (_sessionID, operation) => {
|
||||||
|
await operation()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pendingRetryMs: 1_000,
|
||||||
|
acceptedMessageSkewMs: 5_000,
|
||||||
|
toolCallDeferMaxMs: 5_000,
|
||||||
|
failureRequeueWindowMs: 5_000,
|
||||||
|
userMessageInProgressWindowMs: 2_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
notifier.queuePendingParentWake(
|
||||||
|
"parent-fresh-tool-activity",
|
||||||
|
"task complete",
|
||||||
|
{ agent: "sisyphus" },
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
const pendingWake = notifier.getPendingParentWakes().get("parent-fresh-tool-activity")
|
||||||
|
expect(pendingWake).toBeDefined()
|
||||||
|
if (!pendingWake) {
|
||||||
|
throw new Error("Missing pending parent wake")
|
||||||
|
}
|
||||||
|
pendingWake.toolCallDeferralStartedAt = 90_000
|
||||||
|
|
||||||
|
try {
|
||||||
|
// when
|
||||||
|
const decision = await notifier["shouldDeferParentWakeForSessionHistory"]("parent-fresh-tool-activity", pendingWake)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(decision).toEqual({ defer: true, skipPromptGateToolStateCheck: false })
|
||||||
|
} finally {
|
||||||
|
Date.now = originalDateNow
|
||||||
|
notifier.shutdown()
|
||||||
|
releaseAllPromptAsyncReservationsForTesting()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
type ParentWakeMessageTime = {
|
||||||
|
readonly created?: unknown
|
||||||
|
readonly updated?: unknown
|
||||||
|
readonly completed?: unknown
|
||||||
|
readonly start?: unknown
|
||||||
|
readonly end?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParentWakeMessageActivityPart = {
|
||||||
|
readonly time?: ParentWakeMessageTime
|
||||||
|
readonly state?: {
|
||||||
|
readonly time?: ParentWakeMessageTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParentWakeMessageActivity = {
|
||||||
|
readonly info?: {
|
||||||
|
readonly time?: ParentWakeMessageTime
|
||||||
|
}
|
||||||
|
readonly time?: ParentWakeMessageTime
|
||||||
|
readonly parts?: readonly ParentWakeMessageActivityPart[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function timestampFromUnknown(value: unknown): number | undefined {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestTimestamp(...values: readonly unknown[]): number | undefined {
|
||||||
|
let latest: number | undefined
|
||||||
|
for (const value of values) {
|
||||||
|
const timestamp = timestampFromUnknown(value)
|
||||||
|
if (timestamp === undefined) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (latest === undefined || timestamp > latest) {
|
||||||
|
latest = timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return latest
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestTimeActivity(time: ParentWakeMessageTime | undefined): number | undefined {
|
||||||
|
if (!time) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return latestTimestamp(time.created, time.updated, time.completed, time.start, time.end)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getParentWakeMessageCreatedAt(message: ParentWakeMessageActivity): number | undefined {
|
||||||
|
return timestampFromUnknown(message.info?.time?.created ?? message.time?.created)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getParentWakeMessageActivityAt(message: ParentWakeMessageActivity): number | undefined {
|
||||||
|
let latest = latestTimestamp(
|
||||||
|
latestTimeActivity(message.info?.time),
|
||||||
|
latestTimeActivity(message.time),
|
||||||
|
)
|
||||||
|
for (const part of message.parts ?? []) {
|
||||||
|
const partActivity = latestTimestamp(
|
||||||
|
latestTimeActivity(part.time),
|
||||||
|
latestTimeActivity(part.state?.time),
|
||||||
|
)
|
||||||
|
if (partActivity !== undefined && (latest === undefined || partActivity > latest)) {
|
||||||
|
latest = partActivity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return latest
|
||||||
|
}
|
||||||
@@ -7,8 +7,12 @@ import {
|
|||||||
} from "../../shared"
|
} from "../../shared"
|
||||||
import { isSessionActive as isOpenCodeSessionActive, settleAfterSessionIdle } from "../../hooks/shared/session-idle-settle"
|
import { isSessionActive as isOpenCodeSessionActive, settleAfterSessionIdle } from "../../hooks/shared/session-idle-settle"
|
||||||
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../../hooks/shared/prompt-async-gate"
|
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../../hooks/shared/prompt-async-gate"
|
||||||
|
import { isPromptMessageInspectionAborted } from "../../shared/prompt-async-gate/message-inspection-error"
|
||||||
import type { PromptDispatchClient } from "../../shared/prompt-async-gate/types"
|
import type { PromptDispatchClient } from "../../shared/prompt-async-gate/types"
|
||||||
import { latestAssistantTurnBlocksInternalPrompt } from "../../shared/prompt-async-gate/pending-tool-turn"
|
import {
|
||||||
|
latestAssistantTurnBlocksInternalPrompt,
|
||||||
|
latestAssistantTurnHasUnansweredQuestion,
|
||||||
|
} from "../../shared/prompt-async-gate/pending-tool-turn"
|
||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import {
|
import {
|
||||||
cloneParentWake,
|
cloneParentWake,
|
||||||
@@ -17,6 +21,7 @@ import {
|
|||||||
type ParentWakePromptContext,
|
type ParentWakePromptContext,
|
||||||
type PendingParentWake,
|
type PendingParentWake,
|
||||||
} from "./parent-wake-dedupe"
|
} from "./parent-wake-dedupe"
|
||||||
|
import { getParentWakeMessageActivityAt, getParentWakeMessageCreatedAt } from "./parent-wake-message-activity"
|
||||||
|
|
||||||
type OpencodeClient = PluginInput["client"]
|
type OpencodeClient = PluginInput["client"]
|
||||||
type ParentWakeNotifierClient = PromptDispatchClient & {
|
type ParentWakeNotifierClient = PromptDispatchClient & {
|
||||||
@@ -32,18 +37,20 @@ type ParentWakeSessionMessage = {
|
|||||||
info?: {
|
info?: {
|
||||||
role?: string
|
role?: string
|
||||||
finish?: string
|
finish?: string
|
||||||
time?: { created?: unknown }
|
time?: { created?: unknown; updated?: unknown; completed?: unknown; start?: unknown; end?: unknown }
|
||||||
}
|
}
|
||||||
role?: string
|
role?: string
|
||||||
finish?: string
|
finish?: string
|
||||||
time?: { created?: unknown }
|
time?: { created?: unknown; updated?: unknown; completed?: unknown; start?: unknown; end?: unknown }
|
||||||
parts?: Array<{
|
parts?: Array<{
|
||||||
type?: string
|
type?: string
|
||||||
text?: string
|
text?: string
|
||||||
synthetic?: boolean
|
synthetic?: boolean
|
||||||
content?: unknown
|
content?: unknown
|
||||||
|
time?: { created?: unknown; updated?: unknown; completed?: unknown; start?: unknown; end?: unknown }
|
||||||
state?: {
|
state?: {
|
||||||
status?: unknown
|
status?: unknown
|
||||||
|
time?: { created?: unknown; updated?: unknown; completed?: unknown; start?: unknown; end?: unknown }
|
||||||
}
|
}
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
@@ -393,7 +400,7 @@ export class ParentWakeNotifier {
|
|||||||
this.dispatchedParentWakeTimers.set(sessionID, timer)
|
this.dispatchedParentWakeTimers.set(sessionID, timer)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadParentWakeSessionMessages(sessionID: string): Promise<ParentWakeSessionMessage[]> {
|
private async loadParentWakeSessionMessages(sessionID: string): Promise<ParentWakeSessionMessage[] | undefined> {
|
||||||
try {
|
try {
|
||||||
const messagesResp = await this.deps.client.session.messages({
|
const messagesResp = await this.deps.client.session.messages({
|
||||||
path: { id: sessionID },
|
path: { id: sessionID },
|
||||||
@@ -405,7 +412,7 @@ export class ParentWakeNotifier {
|
|||||||
sessionID,
|
sessionID,
|
||||||
error,
|
error,
|
||||||
})
|
})
|
||||||
return []
|
return isPromptMessageInspectionAborted(error) ? [] : undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,21 +424,6 @@ export class ParentWakeNotifier {
|
|||||||
return message.info?.finish ?? message.finish
|
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 parentWakePartIsWaitingOnTool(part: NonNullable<ParentWakeSessionMessage["parts"]>[number]): boolean {
|
private parentWakePartIsWaitingOnTool(part: NonNullable<ParentWakeSessionMessage["parts"]>[number]): boolean {
|
||||||
if (
|
if (
|
||||||
part.type !== "tool"
|
part.type !== "tool"
|
||||||
@@ -448,7 +440,7 @@ export class ParentWakeNotifier {
|
|||||||
|
|
||||||
private latestAssistantToolWaitState(messages: ParentWakeSessionMessage[]): {
|
private latestAssistantToolWaitState(messages: ParentWakeSessionMessage[]): {
|
||||||
waiting: boolean
|
waiting: boolean
|
||||||
createdAt?: number
|
activityAt?: number
|
||||||
} {
|
} {
|
||||||
for (let index = messages.length - 1; index >= 0; index--) {
|
for (let index = messages.length - 1; index >= 0; index--) {
|
||||||
const message = messages[index]
|
const message = messages[index]
|
||||||
@@ -460,7 +452,7 @@ export class ParentWakeNotifier {
|
|||||||
const waiting = this.getParentWakeMessageFinish(message) === "tool-calls"
|
const waiting = this.getParentWakeMessageFinish(message) === "tool-calls"
|
||||||
|| message.parts?.some((part) => this.parentWakePartIsWaitingOnTool(part)) === true
|
|| message.parts?.some((part) => this.parentWakePartIsWaitingOnTool(part)) === true
|
||||||
return waiting
|
return waiting
|
||||||
? { waiting: true, createdAt: this.getParentWakeMessageCreatedAt(message) }
|
? { waiting: true, activityAt: getParentWakeMessageActivityAt(message) }
|
||||||
: { waiting: false }
|
: { waiting: false }
|
||||||
}
|
}
|
||||||
if (role === "user") {
|
if (role === "user") {
|
||||||
@@ -522,6 +514,9 @@ export class ParentWakeNotifier {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
const messages = await this.loadParentWakeSessionMessages(sessionID)
|
const messages = await this.loadParentWakeSessionMessages(sessionID)
|
||||||
|
if (!messages) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
for (let index = messages.length - 1; index >= 0; index--) {
|
for (let index = messages.length - 1; index >= 0; index--) {
|
||||||
const message = messages[index]
|
const message = messages[index]
|
||||||
if (!message) {
|
if (!message) {
|
||||||
@@ -532,7 +527,7 @@ export class ParentWakeNotifier {
|
|||||||
if (isSyntheticOrInternalUserMessage(message)) {
|
if (isSyntheticOrInternalUserMessage(message)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const createdAt = this.getParentWakeMessageCreatedAt(message)
|
const createdAt = getParentWakeMessageCreatedAt(message)
|
||||||
if (createdAt === undefined) {
|
if (createdAt === undefined) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -552,7 +547,14 @@ export class ParentWakeNotifier {
|
|||||||
wake: PendingParentWake,
|
wake: PendingParentWake,
|
||||||
): Promise<ToolWaitDeferralDecision> {
|
): Promise<ToolWaitDeferralDecision> {
|
||||||
const messages = await this.loadParentWakeSessionMessages(sessionID)
|
const messages = await this.loadParentWakeSessionMessages(sessionID)
|
||||||
|
if (!messages) {
|
||||||
|
log("[background-agent] Deferred parent wake because parent messages could not be inspected:", {
|
||||||
|
sessionID,
|
||||||
|
})
|
||||||
|
return { defer: true, skipPromptGateToolStateCheck: false }
|
||||||
|
}
|
||||||
const latestAssistantBlocksPrompt = latestAssistantTurnBlocksInternalPrompt(messages)
|
const latestAssistantBlocksPrompt = latestAssistantTurnBlocksInternalPrompt(messages)
|
||||||
|
const latestAssistantHasUnansweredQuestion = latestAssistantTurnHasUnansweredQuestion(messages)
|
||||||
const toolWaitState = this.latestAssistantToolWaitState(messages)
|
const toolWaitState = this.latestAssistantToolWaitState(messages)
|
||||||
if (!latestAssistantBlocksPrompt) {
|
if (!latestAssistantBlocksPrompt) {
|
||||||
delete wake.toolCallDeferralStartedAt
|
delete wake.toolCallDeferralStartedAt
|
||||||
@@ -560,9 +562,15 @@ export class ParentWakeNotifier {
|
|||||||
}
|
}
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
wake.toolCallDeferralStartedAt ??= now
|
wake.toolCallDeferralStartedAt ??= now
|
||||||
const latestToolWaitAgeMs = toolWaitState.createdAt === undefined
|
if (latestAssistantHasUnansweredQuestion) {
|
||||||
|
log("[background-agent] Deferred parent wake because latest assistant question awaits user response:", {
|
||||||
|
sessionID,
|
||||||
|
})
|
||||||
|
return { defer: true, skipPromptGateToolStateCheck: false }
|
||||||
|
}
|
||||||
|
const latestToolWaitAgeMs = toolWaitState.activityAt === undefined
|
||||||
? 0
|
? 0
|
||||||
: now - toolWaitState.createdAt
|
: now - toolWaitState.activityAt
|
||||||
const deferAge = now - wake.toolCallDeferralStartedAt
|
const deferAge = now - wake.toolCallDeferralStartedAt
|
||||||
if (
|
if (
|
||||||
wake.shouldReply
|
wake.shouldReply
|
||||||
@@ -594,8 +602,11 @@ export class ParentWakeNotifier {
|
|||||||
}
|
}
|
||||||
const dispatchedAt = wake.dispatchedAt
|
const dispatchedAt = wake.dispatchedAt
|
||||||
const messages = await this.loadParentWakeSessionMessages(sessionID)
|
const messages = await this.loadParentWakeSessionMessages(sessionID)
|
||||||
|
if (!messages) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
return messages.some((message) => {
|
return messages.some((message) => {
|
||||||
const createdAt = this.getParentWakeMessageCreatedAt(message)
|
const createdAt = getParentWakeMessageCreatedAt(message)
|
||||||
if (createdAt === undefined) {
|
if (createdAt === undefined) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -581,7 +581,7 @@ describe("ParentWakeNotifier — user message race guard (issue #4120)", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("#given only an internal user tail is fresh #when flushing pending wake #then user race guard does not defer", async () => {
|
test("#given only an internal user tail is fresh #when flushing pending wake #then parent wake remains deferred", async () => {
|
||||||
// given
|
// given
|
||||||
const originalDateNow = Date.now
|
const originalDateNow = Date.now
|
||||||
Date.now = () => 100_000
|
Date.now = () => 100_000
|
||||||
@@ -615,8 +615,8 @@ describe("ParentWakeNotifier — user message race guard (issue #4120)", () => {
|
|||||||
await notifier.flushPendingParentWake("parent-internal-tail-user-race")
|
await notifier.flushPendingParentWake("parent-internal-tail-user-race")
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(promptAsyncCalls).toHaveLength(1)
|
expect(promptAsyncCalls).toHaveLength(0)
|
||||||
expect(notifier.getPendingParentWakes().has("parent-internal-tail-user-race")).toBe(false)
|
expect(notifier.getPendingParentWakes().has("parent-internal-tail-user-race")).toBe(true)
|
||||||
} finally {
|
} finally {
|
||||||
Date.now = originalDateNow
|
Date.now = originalDateNow
|
||||||
notifier.shutdown()
|
notifier.shutdown()
|
||||||
|
|||||||
Reference in New Issue
Block a user