fix: deliver restart notice explicitly before agent resume

Restore deliverOutboundPayloads() to send the restart summary
deterministically before calling agentCommand() for the agent resume
turn. Previously the notice was only delivered as input to the model
via agentCommand(), making it model-dependent: if the model rewrote or
omitted the content, the user would never see the restart summary/note.

The new two-step flow:
1. deliverOutboundPayloads() — guaranteed delivery of the exact restart
   notice (model-independent). Restores the Slack replyToId mapping
   from main that ensures threaded replies land in the right thread.
2. agentCommand() — agent resume turn so the agent can continue
   autonomously and optionally provide additional context.

Update test to assert deliverOutboundPayloads fires before agentCommand
and verify the two-step ordering is preserved.
This commit is contained in:
Bryan Marty 2026-03-08 19:47:18 +00:00
parent 045bf781a4
commit 80538c607d
No known key found for this signature in database
2 changed files with 59 additions and 8 deletions

View File

@ -25,6 +25,8 @@ const mocks = vi.hoisted(() => ({
})),
normalizeChannelId: vi.fn((channel: string) => channel),
resolveOutboundTarget: vi.fn(() => ({ ok: true as const, to: "+15550002" })),
deliverOutboundPayloads: vi.fn(async () => undefined),
buildOutboundSessionContext: vi.fn(() => ({ agentId: "main", sessionKey: "agent:main:main" })),
agentCommand: vi.fn(async () => undefined),
enqueueSystemEvent: vi.fn(),
defaultRuntime: {},
@ -69,6 +71,14 @@ vi.mock("../infra/outbound/targets.js", () => ({
resolveOutboundTarget: mocks.resolveOutboundTarget,
}));
vi.mock("../infra/outbound/deliver.js", () => ({
deliverOutboundPayloads: mocks.deliverOutboundPayloads,
}));
vi.mock("../infra/outbound/session-context.js", () => ({
buildOutboundSessionContext: mocks.buildOutboundSessionContext,
}));
vi.mock("../commands/agent.js", () => ({
agentCommand: mocks.agentCommand,
}));
@ -88,9 +98,21 @@ describe("scheduleRestartSentinelWake", () => {
vi.clearAllMocks();
});
it("calls agentCommand with resolved channel, to, and sessionKey after restart", async () => {
it("delivers restart notice directly then resumes agent after restart", async () => {
await scheduleRestartSentinelWake({ deps: {} as never });
// Step 1: deterministic delivery (model-independent)
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
expect.objectContaining({
channel: "whatsapp",
to: "+15550002",
accountId: "acct-2",
payloads: [{ text: "restart message" }],
bestEffort: true,
}),
);
// Step 2: agent resume turn
expect(mocks.agentCommand).toHaveBeenCalledWith(
expect.objectContaining({
message: "restart message",
@ -105,6 +127,12 @@ describe("scheduleRestartSentinelWake", () => {
mocks.defaultRuntime,
{},
);
// Verify delivery happened before resume
const deliverOrder = mocks.deliverOutboundPayloads.mock.invocationCallOrder[0];
const agentOrder = mocks.agentCommand.mock.invocationCallOrder[0];
expect(deliverOrder).toBeLessThan(agentOrder);
expect(mocks.enqueueSystemEvent).not.toHaveBeenCalled();
});

View File

@ -4,6 +4,8 @@ import type { CliDeps } from "../cli/deps.js";
import { agentCommand } from "../commands/agent.js";
import { resolveMainSessionKeyFromConfig } from "../config/sessions.js";
import { parseSessionThreadInfo } from "../config/sessions/delivery-info.js";
import { deliverOutboundPayloads } from "../infra/outbound/deliver.js";
import { buildOutboundSessionContext } from "../infra/outbound/session-context.js";
import { resolveOutboundTarget } from "../infra/outbound/targets.js";
import {
consumeRestartSentinel,
@ -76,14 +78,35 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) {
sessionThreadId ??
(origin?.threadId != null ? String(origin.threadId) : undefined);
// Step 1: deliver the restart notice deterministically — model-independent, guaranteed.
// Slack uses replyToId (thread_ts) for threading; deliverOutboundPayloads does not do
// this mapping automatically, so we convert here. See #17716.
const isSlack = channel === "slack";
const replyToId = isSlack && threadId != null && threadId !== "" ? String(threadId) : undefined;
const resolvedThreadId = isSlack ? undefined : threadId;
const outboundSession = buildOutboundSessionContext({ cfg, sessionKey });
try {
await deliverOutboundPayloads({
cfg,
channel,
to: resolved.to,
accountId: origin?.accountId,
replyToId,
threadId: resolvedThreadId,
payloads: [{ text: message }],
session: outboundSession,
bestEffort: true,
});
} catch {
// bestEffort: true means this should not throw, but guard anyway
}
// Step 2: trigger an agent resume turn so the agent can continue autonomously
// after restart. The model sees the restart context and can respond/take actions.
// This is safe post-restart: scheduleRestartSentinelWake() runs in the new process
// with zero in-flight replies, so the pre-restart race condition (ab4a08a82) does
// not apply here.
try {
// Use agentCommand() rather than deliverOutboundPayloads() so the restart
// message is a proper agent turn: the user is notified AND the agent sees
// the message in its conversation history and can resume autonomously.
//
// This is safe post-restart because scheduleRestartSentinelWake() runs in
// the new process, where there are zero in-flight replies. The pre-restart
// race condition fixed in ab4a08a82 does not apply here.
await agentCommand(
{
message,