fix(prompt-gate): harden sync and team prompt dispatch
This commit is contained in:
@@ -94,6 +94,7 @@ export async function runAggressiveTruncationStrategy(params: {
|
||||
sessionID: params.sessionID,
|
||||
source: "auto-compact",
|
||||
settleMs: 0,
|
||||
queueBehavior: "defer",
|
||||
input: {
|
||||
path: { id: params.sessionID },
|
||||
body: {
|
||||
|
||||
@@ -100,6 +100,7 @@ export async function injectBoulderContinuation(input: {
|
||||
sessionID,
|
||||
source: HOOK_NAME,
|
||||
settleMs: idleSettleMs,
|
||||
queueBehavior: "defer",
|
||||
input: {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
|
||||
@@ -305,6 +305,7 @@ export async function handleAtlasSessionIdle(input: {
|
||||
sessionID,
|
||||
source: HOOK_NAME,
|
||||
settleMs: options?.idleSettleMs,
|
||||
queueBehavior: "defer",
|
||||
input: {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
|
||||
@@ -114,6 +114,7 @@ export function createSessionEventHandler(
|
||||
client: ctx.client,
|
||||
sessionID,
|
||||
source: "claude-code-stop-hook:inject-prompt",
|
||||
queueBehavior: "defer",
|
||||
input: {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test"
|
||||
import { setCompactionAgentConfigCheckpoint } from "../../shared/compaction-agent-config-checkpoint"
|
||||
import {
|
||||
dispatchInternalPrompt,
|
||||
releaseAllPromptAsyncReservationsForTesting,
|
||||
releasePromptAsyncReservation,
|
||||
} from "../shared/prompt-async-gate"
|
||||
@@ -155,6 +156,63 @@ describe("createCompactionContextInjector recovery", () => {
|
||||
expect(promptAsyncRecorder.calls[0]?.body.tools).toEqual({ bash: true })
|
||||
})
|
||||
|
||||
it("#given recovery is blocked by a peer prompt hold #when compaction fires again after the hold is released #then queued recovery is not treated as completed", async () => {
|
||||
//#given
|
||||
const promptAsyncRecorder = createPromptAsyncRecorder()
|
||||
const sessionID = "ses_recovery_peer_hold"
|
||||
setCompactionAgentConfigCheckpoint(sessionID, {
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
tools: { bash: true },
|
||||
})
|
||||
const incompletePromptConfig = [
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
},
|
||||
},
|
||||
]
|
||||
const ctx = createMockContext(
|
||||
[incompletePromptConfig],
|
||||
promptAsyncRecorder.promptAsync,
|
||||
)
|
||||
const hook = createCompactionContextInjector({ ctx })
|
||||
const peerHold = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client: ctx.client,
|
||||
sessionID,
|
||||
source: "test-peer-hold",
|
||||
settleMs: 0,
|
||||
postDispatchHoldMs: 1000,
|
||||
input: {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
parts: [{ type: "text", text: "peer message" }],
|
||||
},
|
||||
},
|
||||
})
|
||||
promptAsyncRecorder.calls.splice(0)
|
||||
|
||||
//#when
|
||||
await hook.event({
|
||||
event: { type: "session.compacted", properties: { sessionID } },
|
||||
})
|
||||
const released = releasePromptAsyncReservation(sessionID, "test-release", {
|
||||
reservedBy: "test-peer-hold",
|
||||
})
|
||||
await hook.event({
|
||||
event: { type: "session.compacted", properties: { sessionID } },
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(peerHold.status).toBe("dispatched")
|
||||
expect(released).toBe(true)
|
||||
expect(promptAsyncRecorder.calls).toHaveLength(1)
|
||||
expect(promptAsyncRecorder.calls[0]?.body.parts[0]?.text).toContain("restore checkpointed session agent configuration")
|
||||
})
|
||||
|
||||
it("marks the recovery prompt as synthetic compaction continuation", async () => {
|
||||
//#given
|
||||
const promptAsyncRecorder = createPromptAsyncRecorder()
|
||||
|
||||
@@ -88,6 +88,7 @@ export function createRecoveryLogic(
|
||||
client: ctx.client,
|
||||
sessionID,
|
||||
source: "compaction-context-injector",
|
||||
queueBehavior: "defer",
|
||||
input: {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
|
||||
@@ -849,7 +849,7 @@ describe("ralph-loop", () => {
|
||||
mockSessionMessages = [
|
||||
{ info: { role: "user" }, parts: [{ type: "text", text: "Build something" }] },
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
info: { role: "assistant", finish: "end_turn" },
|
||||
parts: [
|
||||
{ type: "reasoning", text: "I am done now. <promise>REASONING_DONE</promise>" },
|
||||
],
|
||||
@@ -1398,7 +1398,7 @@ Original task: Build something`
|
||||
|
||||
mockSessionMessages = [
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
info: { role: "assistant", finish: "end_turn" },
|
||||
parts: [{ type: "text", text: "All work is complete. <promise>DONE</promise>" }],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -109,7 +109,7 @@ describe("ralph-loop user message race guard", () => {
|
||||
})
|
||||
messagesBySession["session-123"] = [
|
||||
{ info: { role: "user", time: { created: Date.now() - 1_000 } } },
|
||||
{ info: { role: "assistant", agent: "sisyphus", time: { created: Date.now() - 500 } } },
|
||||
{ info: { role: "assistant", finish: "end_turn", agent: "sisyphus", time: { created: Date.now() - 500 } } },
|
||||
]
|
||||
|
||||
try {
|
||||
|
||||
@@ -170,6 +170,7 @@ export function createAutoRetryHelpers(deps: HookDeps) {
|
||||
sessionID,
|
||||
source: `runtime-fallback:${source}`,
|
||||
settleMs: 0,
|
||||
queueBehavior: "defer",
|
||||
input: {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
|
||||
@@ -127,6 +127,7 @@ export async function recoverUnavailableTool(
|
||||
sessionID,
|
||||
source: "session-recovery-unavailable-tool",
|
||||
queueBehavior: "defer",
|
||||
checkToolState: false,
|
||||
input: promptInput,
|
||||
})
|
||||
if (promptResult.status === "failed" && isAmbiguousPromptDispatchFailure(promptResult.error)) {
|
||||
|
||||
@@ -96,7 +96,7 @@ describe("dispatchInternalPrompt", () => {
|
||||
expect(calls).toEqual(["sync:ses_unified_sync"])
|
||||
})
|
||||
|
||||
test("#given async dispatch holds a session reservation #when sync mode targets the same session #then the unified service suppresses the duplicate", async () => {
|
||||
test("#given async dispatch holds a session reservation #when sync mode targets the same session #then the unified service defers the duplicate", async () => {
|
||||
// given
|
||||
const calls: string[] = []
|
||||
const client = {
|
||||
@@ -131,7 +131,7 @@ describe("dispatchInternalPrompt", () => {
|
||||
|
||||
// then
|
||||
expect(first.status).toBe("dispatched")
|
||||
expect(second).toEqual({ status: "queued", queuedBy: "test:unified-shared:first", position: 1 })
|
||||
expect(second).toEqual({ status: "reserved", reservedBy: "test:unified-shared:first" })
|
||||
expect(calls).toEqual(["async"])
|
||||
})
|
||||
|
||||
@@ -671,6 +671,46 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
|
||||
expect(promptCalls).toBe(0)
|
||||
})
|
||||
|
||||
test("#given latest assistant turn is still streaming without a finish reason #when an internal promptAsync is requested #then no prompt is sent", async () => {
|
||||
// given
|
||||
let promptCalls = 0
|
||||
const client = {
|
||||
session: {
|
||||
status: async () => ({ data: { ses_streaming_assistant: { type: "idle" } } }),
|
||||
messages: async () => ({
|
||||
data: [
|
||||
{
|
||||
info: { id: "msg_user", role: "user" },
|
||||
parts: [{ type: "text", text: "run work" }],
|
||||
},
|
||||
{
|
||||
info: { id: "msg_assistant", role: "assistant" },
|
||||
parts: [{ type: "reasoning", text: "still thinking" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
promptAsync: async () => {
|
||||
promptCalls += 1
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// when
|
||||
const result = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client,
|
||||
sessionID: "ses_streaming_assistant",
|
||||
input: { path: { id: "ses_streaming_assistant" }, body: { parts: [] } },
|
||||
source: "test:streaming-assistant",
|
||||
settleMs: 0,
|
||||
postDispatchHoldMs: 0,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result.status).toBe("queued")
|
||||
expect(promptCalls).toBe(0)
|
||||
})
|
||||
|
||||
test("#given internal user tail follows an assistant waiting on tools #when an internal promptAsync is requested #then no prompt is sent", async () => {
|
||||
// given
|
||||
let promptCalls = 0
|
||||
@@ -1170,7 +1210,7 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
|
||||
|
||||
// then
|
||||
expect(firstResult.status).toBe("dispatched")
|
||||
expect(second.status).toBe("queued")
|
||||
expect(second.status).toBe("reserved")
|
||||
expect(promptCalls).toBe(1)
|
||||
})
|
||||
|
||||
@@ -1200,7 +1240,7 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
|
||||
|
||||
// then
|
||||
expect(firstResult.status).toBe("dispatched")
|
||||
expect(second.status).toBe("queued")
|
||||
expect(second.status).toBe("reserved")
|
||||
expect(promptCalls).toBe(1)
|
||||
})
|
||||
|
||||
|
||||
@@ -567,6 +567,49 @@ describe("createTeamIdleWakeHint", () => {
|
||||
expect(processedEntries).toContain(`${messageId}.json`)
|
||||
})
|
||||
|
||||
test("#given a pending live-delivery ack and later unread message #when member idles after the live reply #then it wakes the member for the unread message", async () => {
|
||||
// given
|
||||
const baseDir = await createTemporaryBaseDir()
|
||||
const config = createConfig(baseDir)
|
||||
const teamRunId = randomUUID()
|
||||
const pendingMessageId = randomUUID()
|
||||
const unreadMessageId = randomUUID()
|
||||
await seedRuntimeState(createRuntimeState(teamRunId, [pendingMessageId]), config)
|
||||
await seedReservedUnreadMessage(teamRunId, config, pendingMessageId, "already live delivered", 100)
|
||||
await seedUnreadMessage(teamRunId, config, unreadMessageId, "waiting in inbox", 200)
|
||||
|
||||
const promptInputs: WakeHintPromptInput[] = []
|
||||
const promptAsyncSpy = mock(async (input: WakeHintPromptInput) => {
|
||||
promptInputs.push(input)
|
||||
return {}
|
||||
})
|
||||
const handler = createTeamIdleWakeHint({
|
||||
directory: "/tmp/project",
|
||||
client: { session: { promptAsync: promptAsyncSpy } },
|
||||
}, config)
|
||||
|
||||
// when
|
||||
await handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "member-session" },
|
||||
},
|
||||
})
|
||||
|
||||
// then
|
||||
expect(promptAsyncSpy).toHaveBeenCalledTimes(1)
|
||||
expect(promptInputs[0]?.body.parts[0]?.text).toContain("1 new team messages")
|
||||
|
||||
const runtimeState = await loadRuntimeState(teamRunId, config)
|
||||
expect(runtimeState.members[0]?.pendingInjectedMessageIds).toEqual([])
|
||||
|
||||
const inboxDir = getInboxDir(resolveBaseDir(config), teamRunId, "worker")
|
||||
const processedEntries = await readdir(path.join(inboxDir, "processed"))
|
||||
expect(processedEntries).toContain(`${pendingMessageId}.json`)
|
||||
const inboxEntries = await readdir(inboxDir)
|
||||
expect(inboxEntries).toContain(`${unreadMessageId}.json`)
|
||||
})
|
||||
|
||||
test("acks pending lead messages on idle without sending a wake hint", async () => {
|
||||
// given
|
||||
const baseDir = await createTemporaryBaseDir()
|
||||
|
||||
@@ -82,6 +82,13 @@ export function createTeamIdleWakeHint(ctx: TeamIdleWakeHintContext, config: Tea
|
||||
: member
|
||||
)),
|
||||
}), config)
|
||||
log("team idle handled pending live delivery ack", {
|
||||
event: "team-mode-idle-pending-ack",
|
||||
teamRunId: runtimeState.teamRunId,
|
||||
memberName: memberEntry.name,
|
||||
sessionID,
|
||||
ackedCount: pendingInjectedMessageIds.length,
|
||||
})
|
||||
}
|
||||
|
||||
const unreadMessages = await listUnreadMessages(runtimeState.teamRunId, memberEntry.name, config)
|
||||
@@ -146,6 +153,7 @@ export function createTeamIdleWakeHint(ctx: TeamIdleWakeHintContext, config: Tea
|
||||
sessionID,
|
||||
source: "team-idle-wake-hint",
|
||||
settleMs: options?.idleSettleMs,
|
||||
queueBehavior: "defer",
|
||||
input: {
|
||||
path: { id: sessionID },
|
||||
body: buildMemberPromptBody(memberEntry, buildWakeHint(unreadMessages.length)),
|
||||
|
||||
@@ -238,7 +238,7 @@ describe("injectContinuation", () => {
|
||||
expect(capturedBody?.variant).toBe("max")
|
||||
})
|
||||
|
||||
test("#given a peer-message hold survives an unrelated release #when todo continuation injects #then it queues behind the peer message", async () => {
|
||||
test("#given a peer-message hold survives an unrelated release #when todo continuation injects #then it does not record a queued prompt as injected", async () => {
|
||||
// given
|
||||
const sessionID = "ses_todo_reserved_by_peer_message"
|
||||
let promptCalls = 0
|
||||
@@ -286,7 +286,8 @@ describe("injectContinuation", () => {
|
||||
expect(peerMessageResult.status).toBe("dispatched")
|
||||
expect(promptCalls).toBe(1)
|
||||
expect(state.inFlight).toBe(false)
|
||||
expect(state.lastInjectedAt).toBeGreaterThan(0)
|
||||
expect(state.lastInjectedAt).toBe(0)
|
||||
expect(state.awaitingPostInjectionProgressCheck).not.toBe(true)
|
||||
})
|
||||
|
||||
test("#given promptAsync may have accepted before EOF #when continuation injection observes the failure #then it records an optimistic injection", async () => {
|
||||
|
||||
@@ -194,6 +194,7 @@ ${todoList}`
|
||||
sessionID,
|
||||
source: HOOK_NAME,
|
||||
settleMs: 0,
|
||||
queueBehavior: "defer",
|
||||
input: {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
|
||||
@@ -1225,7 +1225,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
|
||||
mockMessages = [
|
||||
{ info: { id: "msg-1", role: "user" } },
|
||||
{ info: { id: "msg-2", role: "assistant" } },
|
||||
{ info: { id: "msg-2", role: "assistant", finish: "stop" } },
|
||||
]
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
@@ -1248,7 +1248,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
setMainSession(sessionID)
|
||||
|
||||
mockMessages = [
|
||||
{ info: { id: "msg-1", role: "assistant" } },
|
||||
{ info: { id: "msg-1", role: "assistant", finish: "stop" } },
|
||||
{ info: { id: "msg-2", role: "user" } },
|
||||
]
|
||||
|
||||
@@ -1294,7 +1294,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
setMainSession(sessionID)
|
||||
mockMessages = [
|
||||
{ info: { id: "msg-1", role: "user" } },
|
||||
{ info: { id: "msg-2", role: "assistant" } },
|
||||
{ info: { id: "msg-2", role: "assistant", finish: "stop" } },
|
||||
]
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
@@ -1324,7 +1324,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
setMainSession(sessionID)
|
||||
mockMessages = [
|
||||
{ info: { id: "msg-1", role: "user" } },
|
||||
{ info: { id: "msg-2", role: "assistant" } },
|
||||
{ info: { id: "msg-2", role: "assistant", finish: "stop" } },
|
||||
]
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
@@ -1355,7 +1355,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
setMainSession(sessionID)
|
||||
mockMessages = [
|
||||
{ info: { id: "msg-1", role: "user" } },
|
||||
{ info: { id: "msg-2", role: "assistant" } },
|
||||
{ info: { id: "msg-2", role: "assistant", finish: "stop" } },
|
||||
]
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
@@ -1387,7 +1387,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
setMainSession(sessionID)
|
||||
mockMessages = [
|
||||
{ info: { id: "msg-1", role: "user" } },
|
||||
{ info: { id: "msg-2", role: "assistant" } },
|
||||
{ info: { id: "msg-2", role: "assistant", finish: "stop" } },
|
||||
]
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
@@ -1426,7 +1426,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
setMainSession(sessionID)
|
||||
mockMessages = [
|
||||
{ info: { id: "msg-1", role: "user" } },
|
||||
{ info: { id: "msg-2", role: "assistant" } },
|
||||
{ info: { id: "msg-2", role: "assistant", finish: "stop" } },
|
||||
]
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
@@ -1465,7 +1465,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
setMainSession(sessionID)
|
||||
mockMessages = [
|
||||
{ info: { id: "msg-1", role: "user" } },
|
||||
{ info: { id: "msg-2", role: "assistant" } },
|
||||
{ info: { id: "msg-2", role: "assistant", finish: "stop" } },
|
||||
]
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
@@ -1504,7 +1504,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
setMainSession(sessionID)
|
||||
mockMessages = [
|
||||
{ info: { id: "msg-1", role: "user" } },
|
||||
{ info: { id: "msg-2", role: "assistant" } },
|
||||
{ info: { id: "msg-2", role: "assistant", finish: "stop" } },
|
||||
]
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
@@ -1542,7 +1542,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
setMainSession(sessionID)
|
||||
mockMessages = [
|
||||
{ info: { id: "msg-1", role: "user" } },
|
||||
{ info: { id: "msg-2", role: "assistant" } },
|
||||
{ info: { id: "msg-2", role: "assistant", finish: "stop" } },
|
||||
]
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
@@ -1619,7 +1619,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
// OpenCode returns assistant messages with flat modelID/providerID, not nested model object
|
||||
const mockMessagesWithAssistant = [
|
||||
{ info: { id: "msg-1", role: "user", agent: "sisyphus", model: { providerID: "openai", modelID: "gpt-5.4" } } },
|
||||
{ info: { id: "msg-2", role: "assistant", agent: "sisyphus", modelID: "gpt-5.4", providerID: "openai" } },
|
||||
{ info: { id: "msg-2", role: "assistant", finish: "stop", agent: "sisyphus", modelID: "gpt-5.4", providerID: "openai" } },
|
||||
]
|
||||
|
||||
const mockInput = {
|
||||
@@ -1677,8 +1677,8 @@ describe("todo-continuation-enforcer", () => {
|
||||
|
||||
const mockMessagesWithCompaction = [
|
||||
{ info: { id: "msg-1", role: "user", agent: "sisyphus", model: { providerID: "anthropic", modelID: "claude-sonnet-4-6" } } },
|
||||
{ info: { id: "msg-2", role: "assistant", agent: "sisyphus", modelID: "claude-sonnet-4-6", providerID: "anthropic" } },
|
||||
{ info: { id: "msg-3", role: "assistant", agent: "compaction", modelID: "claude-sonnet-4-6", providerID: "anthropic" } },
|
||||
{ info: { id: "msg-2", role: "assistant", finish: "stop", agent: "sisyphus", modelID: "claude-sonnet-4-6", providerID: "anthropic" } },
|
||||
{ info: { id: "msg-3", role: "assistant", finish: "stop", agent: "compaction", modelID: "claude-sonnet-4-6", providerID: "anthropic" } },
|
||||
]
|
||||
|
||||
const mockInput = {
|
||||
@@ -1730,7 +1730,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
setMainSession(sessionID)
|
||||
|
||||
const mockMessagesOnlyCompaction = [
|
||||
{ info: { id: "msg-1", role: "assistant", agent: "compaction" } },
|
||||
{ info: { id: "msg-1", role: "assistant", finish: "stop", agent: "compaction" } },
|
||||
]
|
||||
|
||||
const mockInput = {
|
||||
@@ -1783,7 +1783,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
setMainSession(sessionID)
|
||||
|
||||
const mockMessagesWithCompactionMarker = [
|
||||
{ info: { id: "msg-1", role: "assistant", agent: "sisyphus", modelID: "claude-sonnet-4-6", providerID: "anthropic" } },
|
||||
{ info: { id: "msg-1", role: "assistant", finish: "stop", agent: "sisyphus", modelID: "claude-sonnet-4-6", providerID: "anthropic" } },
|
||||
{
|
||||
info: { id: "msg-2", role: "user", agent: "atlas", model: { providerID: "openai", modelID: "gpt-5.4" } },
|
||||
parts: [{ type: "compaction" }],
|
||||
@@ -1840,8 +1840,8 @@ describe("todo-continuation-enforcer", () => {
|
||||
|
||||
const mockMessagesPrometheusCompacted = [
|
||||
{ info: { id: "msg-1", role: "user", agent: "prometheus" } },
|
||||
{ info: { id: "msg-2", role: "assistant", agent: "prometheus" } },
|
||||
{ info: { id: "msg-3", role: "assistant", agent: "compaction" } },
|
||||
{ info: { id: "msg-2", role: "assistant", finish: "stop", agent: "prometheus" } },
|
||||
{ info: { id: "msg-3", role: "assistant", finish: "stop", agent: "compaction" } },
|
||||
]
|
||||
|
||||
const mockInput = {
|
||||
@@ -1896,7 +1896,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
|
||||
const mockMessagesNoAgent = [
|
||||
{ info: { id: "msg-1", role: "user" } },
|
||||
{ info: { id: "msg-2", role: "assistant" } },
|
||||
{ info: { id: "msg-2", role: "assistant", finish: "stop" } },
|
||||
]
|
||||
|
||||
const mockInput = {
|
||||
@@ -2103,7 +2103,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
setMainSession(sessionID)
|
||||
mockMessages = [
|
||||
{ info: { id: "msg-1", role: "user" } },
|
||||
{ info: { id: "msg-2", role: "assistant" } },
|
||||
{ info: { id: "msg-2", role: "assistant", finish: "stop" } },
|
||||
]
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
@@ -2136,7 +2136,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
setMainSession(sessionID)
|
||||
mockMessages = [
|
||||
{ info: { id: "msg-1", role: "user" } },
|
||||
{ info: { id: "msg-2", role: "assistant" } },
|
||||
{ info: { id: "msg-2", role: "assistant", finish: "stop" } },
|
||||
]
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
@@ -2240,7 +2240,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
setMainSession(sessionID)
|
||||
mockMessages = [
|
||||
{ info: { id: "msg-1", role: "user" } },
|
||||
{ info: { id: "msg-2", role: "assistant" } },
|
||||
{ info: { id: "msg-2", role: "assistant", finish: "stop" } },
|
||||
]
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
@@ -383,7 +383,49 @@ describe("unstable-agent-babysitter hook", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("#given the latest main-session message is assistant output after a fresh user message #when it becomes idle #then babysitter may inject a reminder", async () => {
|
||||
test("#given the latest main-session assistant output has finished after a fresh user message #when it becomes idle #then babysitter may inject a reminder", async () => {
|
||||
// given
|
||||
const originalNow = Date.now
|
||||
Date.now = () => 10 * 60 * 1000
|
||||
setMainSession("main-1")
|
||||
const promptCalls: Array<{ input: unknown }> = []
|
||||
const ctx = createMockPluginInput({
|
||||
messagesBySession: {
|
||||
"main-1": [
|
||||
{ info: { role: "user", time: { created: Date.now() - 1_500 } } },
|
||||
{ info: { role: "assistant", time: { created: Date.now() - 500 }, agent: "sisyphus", finish: "stop" } },
|
||||
],
|
||||
"bg-1": [
|
||||
{ info: { role: "assistant" }, parts: [{ type: "thinking", thinking: "deep thought" }] },
|
||||
],
|
||||
},
|
||||
promptCalls,
|
||||
})
|
||||
const backgroundManager = createBackgroundManager([createTask({
|
||||
progress: {
|
||||
toolCalls: 1,
|
||||
lastUpdate: new Date(0),
|
||||
lastMessage: "still working",
|
||||
lastMessageAt: new Date(0),
|
||||
},
|
||||
})])
|
||||
const hook = createUnstableAgentBabysitterHook(ctx, {
|
||||
backgroundManager,
|
||||
config: { timeout_ms: 120000 },
|
||||
})
|
||||
|
||||
try {
|
||||
// when
|
||||
await hook.event({ event: { type: "session.idle", properties: { sessionID: "main-1" } } })
|
||||
|
||||
// then
|
||||
expect(promptCalls.length).toBe(1)
|
||||
} finally {
|
||||
Date.now = originalNow
|
||||
}
|
||||
})
|
||||
|
||||
test("#given the latest main-session assistant output is still streaming after a fresh user message #when it becomes idle #then babysitter does not inject a reminder", async () => {
|
||||
// given
|
||||
const originalNow = Date.now
|
||||
Date.now = () => 10 * 60 * 1000
|
||||
@@ -419,7 +461,7 @@ describe("unstable-agent-babysitter hook", () => {
|
||||
await hook.event({ event: { type: "session.idle", properties: { sessionID: "main-1" } } })
|
||||
|
||||
// then
|
||||
expect(promptCalls.length).toBe(1)
|
||||
expect(promptCalls.length).toBe(0)
|
||||
} finally {
|
||||
Date.now = originalNow
|
||||
}
|
||||
|
||||
@@ -247,6 +247,7 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option
|
||||
sessionID: mainSessionID,
|
||||
source: HOOK_NAME,
|
||||
settleMs: options.idleSettleMs,
|
||||
queueBehavior: "defer",
|
||||
input: {
|
||||
path: { id: mainSessionID },
|
||||
body: {
|
||||
|
||||
Reference in New Issue
Block a user