Fix restart sentinel internal continuations (#88161)

* fix restart sentinel internal continuations

* update gateway prompt snapshots

* stabilize sandbox browser audit timer tests

* drive sandbox audit timeouts deterministically

* drive gh-read timeout tests deterministically

* drive label-open-issues timeout tests deterministically

* document deterministic timeout test timers

* test: preserve deterministic timer setup after rebase
This commit is contained in:
Josh Avant
2026-05-29 19:06:54 -07:00
committed by GitHub
parent dc4f3b57cf
commit 584fa3215c
36 changed files with 333 additions and 211 deletions

View File

@@ -646,10 +646,16 @@ describe("runCliAgent spawn path", () => {
runId: "run-claude-channel-wrapper",
messageChannel: "telegram",
messageProvider: "acp",
currentChannelId: "telegram:-100123:topic:42",
currentThreadTs: "42",
currentMessageId: "reply-message-1",
});
expect(params.messageChannel).toBe("telegram");
expect(params.messageProvider).toBe("acp");
expect(params.currentChannelId).toBe("telegram:-100123:topic:42");
expect(params.currentThreadTs).toBe("42");
expect(params.currentMessageId).toBe("reply-message-1");
expect(params.cwd).toBe("/tmp/task-repo");
});

View File

@@ -779,6 +779,9 @@ export function buildRunClaudeCliAgentParams(params: RunClaudeCliAgentParams): R
images: params.images,
messageChannel: params.messageChannel,
messageProvider: params.messageProvider,
currentChannelId: params.currentChannelId,
currentThreadTs: params.currentThreadTs,
currentMessageId: params.currentMessageId,
};
}

View File

@@ -98,6 +98,9 @@ function createTestMcpLoopbackServerConfig(port: number) {
"x-openclaw-agent-id": "${OPENCLAW_MCP_AGENT_ID}",
"x-openclaw-account-id": "${OPENCLAW_MCP_ACCOUNT_ID}",
"x-openclaw-message-channel": "${OPENCLAW_MCP_MESSAGE_CHANNEL}",
"x-openclaw-current-channel-id": "${OPENCLAW_MCP_CURRENT_CHANNEL_ID}",
"x-openclaw-current-thread-ts": "${OPENCLAW_MCP_CURRENT_THREAD_TS}",
"x-openclaw-current-message-id": "${OPENCLAW_MCP_CURRENT_MESSAGE_ID}",
"x-openclaw-inbound-event-kind": "${OPENCLAW_MCP_INBOUND_EVENT_KIND}",
"x-openclaw-source-reply-delivery-mode": "${OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE}",
},
@@ -1147,6 +1150,9 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
cfg: expect.any(Object),
sessionKey: "agent:main:test",
messageProvider: undefined,
currentChannelId: undefined,
currentThreadTs: undefined,
currentMessageId: undefined,
accountId: undefined,
inboundEventKind: undefined,
sourceReplyDeliveryMode: undefined,
@@ -1289,11 +1295,17 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
config: createCliBackendConfig(),
currentInboundEventKind: "room_event",
messageChannel: "telegram",
currentChannelId: "telegram:-100123:topic:42",
currentThreadTs: "42",
currentMessageId: "reply-message-1",
sourceReplyDeliveryMode: "message_tool_only",
});
expect(context.preparedBackend.env).toMatchObject({
OPENCLAW_MCP_MESSAGE_CHANNEL: "telegram",
OPENCLAW_MCP_CURRENT_CHANNEL_ID: "telegram:-100123:topic:42",
OPENCLAW_MCP_CURRENT_THREAD_TS: "42",
OPENCLAW_MCP_CURRENT_MESSAGE_ID: "reply-message-1",
OPENCLAW_MCP_INBOUND_EVENT_KIND: "room_event",
OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE: "message_tool_only",
});

View File

@@ -277,6 +277,10 @@ export async function prepareCliRunContext(
OPENCLAW_MCP_ACCOUNT_ID: params.agentAccountId ?? "",
OPENCLAW_MCP_SESSION_KEY: params.sessionKey ?? "",
OPENCLAW_MCP_MESSAGE_CHANNEL: params.messageChannel ?? params.messageProvider ?? "",
OPENCLAW_MCP_CURRENT_CHANNEL_ID: params.currentChannelId ?? "",
OPENCLAW_MCP_CURRENT_THREAD_TS: params.currentThreadTs ?? "",
OPENCLAW_MCP_CURRENT_MESSAGE_ID:
params.currentMessageId != null ? String(params.currentMessageId) : "",
OPENCLAW_MCP_INBOUND_EVENT_KIND: params.currentInboundEventKind ?? "",
OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE: params.sourceReplyDeliveryMode ?? "",
}
@@ -351,6 +355,9 @@ export async function prepareCliRunContext(
cfg: params.config ?? getRuntimeConfig(),
sessionKey: params.sessionKey ?? "",
messageProvider: params.messageChannel ?? params.messageProvider,
currentChannelId: params.currentChannelId,
currentThreadTs: params.currentThreadTs,
currentMessageId: params.currentMessageId,
accountId: params.agentAccountId,
inboundEventKind: params.currentInboundEventKind,
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,

View File

@@ -70,6 +70,9 @@ export type RunCliAgentParams = {
skillsSnapshot?: SkillSnapshot;
messageChannel?: string;
messageProvider?: string;
currentChannelId?: string;
currentThreadTs?: string;
currentMessageId?: string | number;
agentAccountId?: string;
/** Trusted sender identity bit for channel action auth. */
senderIsOwner?: boolean;

View File

@@ -137,12 +137,15 @@ describe("gateway tool restart continuation", () => {
expect(parameters.properties?.timeoutMs).toMatchObject({ type: "integer", minimum: 1 });
});
it("instructs agents to use continuationMessage when a restart still needs a reply", async () => {
it("instructs agents to use continuationMessage for internal post-restart work", async () => {
const tool = createGatewayTool();
expect(tool.description).toContain("still owe the user a reply");
expect(tool.description).toContain("post-restart work must continue internally");
expect(tool.description).toContain(
"visible follow-up from that turn must use the message tool",
);
expect(tool.description).toContain("continuationMessage");
expect(tool.description).toContain("do not write restart sentinel files directly");
expect(tool.description).toContain("Do not write restart sentinel files directly");
});
it("writes an agentTurn continuation into the restart sentinel", async () => {
@@ -234,9 +237,7 @@ describe("gateway tool restart continuation", () => {
});
});
it("defaults session-scoped restarts to a success continuation", async () => {
const { DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE } =
await import("../../infra/restart-sentinel.js");
it("does not infer a continuation for session-scoped restarts", async () => {
const tool = createGatewayTool({
agentSessionKey: "agent:main:main",
config: {},
@@ -252,10 +253,7 @@ describe("gateway tool restart continuation", () => {
const payload = requireRestartSentinelPayload();
expect(payload.sessionKey).toBe("agent:main:main");
expect(payload.continuation).toEqual({
kind: "agentTurn",
message: DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE,
});
expect(payload.continuation).toBeNull();
});
it("removes the prepared sentinel when restart emission is rejected", async () => {

View File

@@ -369,7 +369,7 @@ export function createGatewayTool(opts?: {
label: "Gateway",
name: "gateway",
description:
"Gateway restart/config/update. Before config edits, use config.schema.lookup with targeted dot path. Prefer config.patch for partial merge; config.apply only full replace. Writes hot-reload or restart as needed. Always pass human `note` for post-restart delivery. If still owe the user a reply, pass one-shot `continuationMessage`; do not write restart sentinel files directly.",
"Gateway restart/config/update. Before config edits, use config.schema.lookup with targeted dot path. Prefer config.patch for partial merge; config.apply only full replace. Writes hot-reload or restart as needed. Always pass human `note` for post-restart delivery. If post-restart work must continue internally, pass one-shot `continuationMessage`; visible follow-up from that turn must use the message tool. Do not write restart sentinel files directly.",
parameters: GatewayToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;

View File

@@ -2015,6 +2015,14 @@ export async function runAgentTurnWithFallback(params: {
originatingChannel: params.followupRun.originatingChannel,
provider: params.sessionCtx.Provider,
});
const cliCurrentThreadId =
params.followupRun.originatingThreadId ?? params.sessionCtx.MessageThreadId;
const isRestartSentinelContinuation =
params.sessionCtx.InputProvenance?.kind === "internal_system" &&
params.sessionCtx.InputProvenance.sourceTool === "restart-sentinel";
const cliCurrentMessageId = isRestartSentinelContinuation
? params.sessionCtx.ReplyToId
: (params.sessionCtx.MessageSidFull ?? params.sessionCtx.MessageSid);
const result = await agentTurnTiming.measure("cli_run", () =>
runCliAgentWithLifecycle({
runId,
@@ -2107,6 +2115,13 @@ export async function runAgentTurnWithFallback(params: {
skillsSnapshot: params.followupRun.run.skillsSnapshot,
messageChannel: params.followupRun.originatingChannel ?? undefined,
messageProvider: hookMessageProvider,
currentChannelId:
params.followupRun.originatingTo ??
params.sessionCtx.OriginatingTo ??
params.sessionCtx.To,
currentThreadTs:
cliCurrentThreadId != null ? String(cliCurrentThreadId) : undefined,
currentMessageId: cliCurrentMessageId,
agentAccountId: params.followupRun.run.agentAccountId,
senderIsOwner: params.followupRun.run.senderIsOwner,
disableTools: params.opts?.disableTools,

View File

@@ -309,4 +309,63 @@ describe("agent-runner-utils", () => {
expect(context.currentChannelId).toBe("channel:123456789012345678");
expect(context.currentMessageId).toBe("msg-9");
});
it("does not expose restart-sentinel synthetic ids as message-tool reply targets", () => {
hoisted.getChannelPluginMock.mockReturnValue({
threading: {
buildToolContext: ({
context,
}: {
context: { To?: string; MessageThreadId?: string | number };
}) => ({
currentChannelId: context.To,
currentThreadTs:
context.MessageThreadId != null ? String(context.MessageThreadId) : undefined,
}),
},
});
const context = buildThreadingToolContext({
sessionCtx: {
Provider: "webchat",
OriginatingChannel: "telegram",
OriginatingTo: "telegram:-1003841603622:topic:928",
MessageThreadId: 928,
MessageSid: "restart-sentinel:agent:main:telegram:agentTurn:123",
InputProvenance: {
kind: "internal_system",
sourceChannel: "telegram",
sourceTool: "restart-sentinel",
},
},
config: {},
hasRepliedRef: undefined,
});
expect(context.currentChannelId).toBe("telegram:-1003841603622:topic:928");
expect(context.currentThreadTs).toBe("928");
expect(context.currentMessageId).toBeUndefined();
});
it("uses restart-sentinel reply target when one exists", () => {
const context = buildThreadingToolContext({
sessionCtx: {
Provider: "webchat",
OriginatingChannel: "whatsapp",
OriginatingTo: "whatsapp:+15550002",
ReplyToId: "provider-reply-id",
MessageSid: "restart-sentinel:agent:main:whatsapp:agentTurn:123",
InputProvenance: {
kind: "internal_system",
sourceChannel: "whatsapp",
sourceTool: "restart-sentinel",
},
},
config: {},
hasRepliedRef: undefined,
});
expect(context.currentChannelId).toBe("whatsapp:+15550002");
expect(context.currentMessageId).toBe("provider-reply-id");
});
});

View File

@@ -105,7 +105,12 @@ export function buildThreadingToolContext(params: {
hasRepliedRef: { value: boolean } | undefined;
}): ChannelThreadingToolContext {
const { sessionCtx, config, hasRepliedRef } = params;
const currentMessageId = sessionCtx.MessageSidFull ?? sessionCtx.MessageSid;
const isRestartSentinelContinuation =
sessionCtx.InputProvenance?.kind === "internal_system" &&
sessionCtx.InputProvenance.sourceTool === "restart-sentinel";
const currentMessageId = isRestartSentinelContinuation
? sessionCtx.ReplyToId
: (sessionCtx.MessageSidFull ?? sessionCtx.MessageSid);
const originProvider = resolveOriginMessageProvider({
originatingChannel: sessionCtx.OriginatingChannel,
provider: sessionCtx.Provider,

View File

@@ -128,9 +128,6 @@ describe("handleRestartCommand", () => {
});
it("writes a routed restart sentinel before restarting from chat", async () => {
const { DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE } =
await import("../../infra/restart-sentinel.js");
const result = await handleRestartCommand(restartCommandParams(), true);
expect(result?.shouldContinue).toBe(false);
@@ -147,10 +144,7 @@ describe("handleRestartCommand", () => {
});
expect(sentinelPayload?.threadId).toBe("thread-1");
expect(sentinelPayload?.message).toBe("/restart");
expect(sentinelPayload?.continuation).toEqual({
kind: "agentTurn",
message: DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE,
});
expect(sentinelPayload?.continuation).toBeNull();
expect(sentinelPayload?.doctorHint).toBe(
"Recommended follow-up: run openclaw doctor --non-interactive in a terminal or approvals-capable OpenClaw surface.",
);
@@ -179,11 +173,7 @@ describe("handleRestartCommand", () => {
expect(sentinelPayload?.kind).toBe("restart");
expect(sentinelPayload?.status).toBe("ok");
expect(sentinelPayload?.sessionKey).toBe("agent:main:telegram:direct:123:thread:thread-1");
expect(sentinelPayload?.continuation).toEqual({
kind: "agentTurn",
message:
"The gateway restart completed successfully. Tell the user OpenClaw restarted successfully and continue any pending work.",
});
expect(sentinelPayload?.continuation).toBeNull();
} finally {
process.removeListener("SIGUSR1", handler);
}

View File

@@ -880,6 +880,10 @@ describe("createFollowupRunner runtime config", () => {
await runner(
createQueuedRun({
originatingChannel: "telegram",
originatingTo: "telegram:-100123:topic:42",
originatingThreadId: "42",
originatingReplyToId: "reply-42",
messageId: "queued-message-1",
run: {
config: runtimeConfig,
sessionId: "session-cli-followup",
@@ -887,6 +891,11 @@ describe("createFollowupRunner runtime config", () => {
model: "claude-opus-4-7",
messageProvider: "telegram",
cwd: "/tmp/task-repo",
inputProvenance: {
kind: "internal_system",
sourceChannel: "telegram",
sourceTool: "restart-sentinel",
},
},
}),
);
@@ -899,6 +908,9 @@ describe("createFollowupRunner runtime config", () => {
expect(call.config).toBe(runtimeConfig);
expect(call.cliSessionId).toBe("cli-session-1");
expect(call.messageChannel).toBe("telegram");
expect(call.currentChannelId).toBe("telegram:-100123:topic:42");
expect(call.currentThreadTs).toBe("42");
expect(call.currentMessageId).toBe("reply-42");
expect(call).toMatchObject({
sessionId: "session-cli-followup",
sessionKey: "main",

View File

@@ -728,6 +728,12 @@ export function createFollowupRunner(params: {
model,
startedAt: cliLifecycleStartedAt,
};
const isRestartSentinelFollowup =
run.inputProvenance?.kind === "internal_system" &&
run.inputProvenance.sourceTool === "restart-sentinel";
const followupCurrentMessageId = isRestartSentinelFollowup
? queued.originatingReplyToId
: queued.messageId;
const result = await runCliAgentWithLifecycle({
runId,
provider: cliExecutionProvider,
@@ -803,6 +809,12 @@ export function createFollowupRunner(params: {
originatingChannel: queued.originatingChannel,
provider: run.messageProvider,
}),
currentChannelId: queued.originatingTo,
currentThreadTs:
queued.originatingThreadId != null
? String(queued.originatingThreadId)
: undefined,
currentMessageId: followupCurrentMessageId,
agentAccountId: run.agentAccountId,
disableTools: opts?.disableTools,
abortSignal: runAbortSignal,
@@ -823,6 +835,12 @@ export function createFollowupRunner(params: {
return result;
}
pendingDeferredCliTerminal = undefined;
const isRestartSentinelFollowup =
run.inputProvenance?.kind === "internal_system" &&
run.inputProvenance.sourceTool === "restart-sentinel";
const followupCurrentMessageId = isRestartSentinelFollowup
? queued.originatingReplyToId
: queued.messageId;
const result = await runEmbeddedAgent({
allowGatewaySubagentBinding: true,
replyOperation,
@@ -840,6 +858,7 @@ export function createFollowupRunner(params: {
queued.originatingThreadId != null
? String(queued.originatingThreadId)
: undefined,
currentMessageId: followupCurrentMessageId,
groupId: run.groupId,
groupChannel: run.groupChannel,
groupSpace: run.groupSpace,

View File

@@ -2408,6 +2408,7 @@ describe("runPreparedReply media-only handling", () => {
ChatType: "group",
OriginatingChannel: "discord",
OriginatingTo: "channel:24680",
ReplyToId: "reply-24680",
AccountId: "work",
},
}),
@@ -2415,6 +2416,7 @@ describe("runPreparedReply media-only handling", () => {
const call = requireRunReplyAgentCall();
expect(call?.followupRun.originatingAccountId).toBe("work");
expect(call?.followupRun.originatingReplyToId).toBe("reply-24680");
});
it("uses transport thread metadata for followup originatingThreadId", async () => {

View File

@@ -1216,6 +1216,7 @@ export async function runPreparedReply(
originatingTo: ctx.OriginatingTo,
originatingAccountId: sessionCtx.AccountId,
originatingThreadId,
originatingReplyToId: sessionCtx.ReplyToId,
originatingChatType: ctx.ChatType,
run: {
agentId,

View File

@@ -75,6 +75,8 @@ export type FollowupRun = {
originatingAccountId?: string;
/** Thread id for reply routing (Telegram topic id or Matrix thread event id). */
originatingThreadId?: string | number;
/** Provider reply target for transports that model threads as message replies. */
originatingReplyToId?: string;
/** Chat type for context-aware threading (e.g., DM vs channel). */
originatingChatType?: string;
run: {

View File

@@ -39,6 +39,9 @@ export function createMcpLoopbackServerConfig(port: number) {
"x-openclaw-agent-id": "${OPENCLAW_MCP_AGENT_ID}",
"x-openclaw-account-id": "${OPENCLAW_MCP_ACCOUNT_ID}",
"x-openclaw-message-channel": "${OPENCLAW_MCP_MESSAGE_CHANNEL}",
"x-openclaw-current-channel-id": "${OPENCLAW_MCP_CURRENT_CHANNEL_ID}",
"x-openclaw-current-thread-ts": "${OPENCLAW_MCP_CURRENT_THREAD_TS}",
"x-openclaw-current-message-id": "${OPENCLAW_MCP_CURRENT_MESSAGE_ID}",
"x-openclaw-inbound-event-kind": "${OPENCLAW_MCP_INBOUND_EVENT_KIND}",
"x-openclaw-source-reply-delivery-mode": "${OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE}",
},

View File

@@ -30,6 +30,9 @@ function logMcpLoopbackHttp(step: string, details: Record<string, unknown>): voi
type McpRequestContext = {
sessionKey: string;
messageProvider: string | undefined;
currentChannelId: string | undefined;
currentThreadTs: string | undefined;
currentMessageId: string | undefined;
accountId: string | undefined;
inboundEventKind: InboundEventKind | undefined;
sourceReplyDeliveryMode: SourceReplyDeliveryMode | undefined;
@@ -188,6 +191,9 @@ export function resolveMcpRequestContext(
sessionKey: resolveScopedSessionKey(cfg, getHeader(req, "x-session-key")),
messageProvider:
normalizeMessageChannel(getHeader(req, "x-openclaw-message-channel")) ?? undefined,
currentChannelId: normalizeOptionalString(getHeader(req, "x-openclaw-current-channel-id")),
currentThreadTs: normalizeOptionalString(getHeader(req, "x-openclaw-current-thread-ts")),
currentMessageId: normalizeOptionalString(getHeader(req, "x-openclaw-current-message-id")),
accountId: normalizeOptionalString(getHeader(req, "x-openclaw-account-id")),
inboundEventKind: normalizeMcpInboundEventKind(getHeader(req, "x-openclaw-inbound-event-kind")),
sourceReplyDeliveryMode: normalizeMcpSourceReplyDeliveryMode(

View File

@@ -23,6 +23,9 @@ export function resolveMcpLoopbackScopedTools(params: {
cfg: OpenClawConfig;
sessionKey: string;
messageProvider: string | undefined;
currentChannelId: string | undefined;
currentThreadTs: string | undefined;
currentMessageId: string | number | undefined;
accountId: string | undefined;
inboundEventKind: InboundEventKind | undefined;
sourceReplyDeliveryMode: SourceReplyDeliveryMode | undefined;
@@ -32,6 +35,9 @@ export function resolveMcpLoopbackScopedTools(params: {
cfg: params.cfg,
sessionKey: params.sessionKey,
messageProvider: params.messageProvider,
currentChannelId: params.currentChannelId,
currentThreadTs: params.currentThreadTs,
currentMessageId: params.currentMessageId,
accountId: params.accountId,
inboundEventKind: params.inboundEventKind,
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
@@ -52,6 +58,9 @@ export class McpLoopbackToolCache {
cfg: OpenClawConfig;
sessionKey: string;
messageProvider: string | undefined;
currentChannelId: string | undefined;
currentThreadTs: string | undefined;
currentMessageId: string | number | undefined;
accountId: string | undefined;
inboundEventKind: InboundEventKind | undefined;
sourceReplyDeliveryMode: SourceReplyDeliveryMode | undefined;
@@ -60,6 +69,9 @@ export class McpLoopbackToolCache {
const cacheKey = [
params.sessionKey,
params.messageProvider ?? "",
params.currentChannelId ?? "",
params.currentThreadTs ?? "",
params.currentMessageId != null ? String(params.currentMessageId) : "",
params.accountId ?? "",
params.inboundEventKind ?? "",
params.sourceReplyDeliveryMode ?? "",
@@ -75,6 +87,9 @@ export class McpLoopbackToolCache {
cfg: params.cfg,
sessionKey: params.sessionKey,
messageProvider: params.messageProvider,
currentChannelId: params.currentChannelId,
currentThreadTs: params.currentThreadTs,
currentMessageId: params.currentMessageId,
accountId: params.accountId,
inboundEventKind: params.inboundEventKind,
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,

View File

@@ -21,6 +21,9 @@ type ScopedToolsCall = {
sessionKey?: string;
accountId?: string;
messageProvider?: string;
currentChannelId?: string;
currentThreadTs?: string;
currentMessageId?: string | number;
inboundEventKind?: string;
sourceReplyDeliveryMode?: string;
senderIsOwner?: boolean;
@@ -170,6 +173,9 @@ describe("mcp loopback server", () => {
"x-session-key": "agent:main:telegram:group:chat123",
"x-openclaw-account-id": "work",
"x-openclaw-message-channel": "telegram",
"x-openclaw-current-channel-id": "telegram:chat123",
"x-openclaw-current-thread-ts": "42",
"x-openclaw-current-message-id": "reply-message-1",
"x-openclaw-inbound-event-kind": "room_event",
"x-openclaw-source-reply-delivery-mode": "message_tool_only",
},
@@ -181,6 +187,9 @@ describe("mcp loopback server", () => {
expect(call.sessionKey).toBe("agent:main:telegram:group:chat123");
expect(call.accountId).toBe("work");
expect(call.messageProvider).toBe("telegram");
expect(call.currentChannelId).toBe("telegram:chat123");
expect(call.currentThreadTs).toBe("42");
expect(call.currentMessageId).toBe("reply-message-1");
expect(call.inboundEventKind).toBe("room_event");
expect(call.sourceReplyDeliveryMode).toBe("message_tool_only");
expect(call.surface).toBe("loopback");
@@ -699,6 +708,15 @@ describe("createMcpLoopbackServerConfig", () => {
expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-message-channel"]).toBe(
"${OPENCLAW_MCP_MESSAGE_CHANNEL}",
);
expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-current-channel-id"]).toBe(
"${OPENCLAW_MCP_CURRENT_CHANNEL_ID}",
);
expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-current-thread-ts"]).toBe(
"${OPENCLAW_MCP_CURRENT_THREAD_TS}",
);
expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-current-message-id"]).toBe(
"${OPENCLAW_MCP_CURRENT_MESSAGE_ID}",
);
expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-source-reply-delivery-mode"]).toBe(
"${OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE}",
);

View File

@@ -106,6 +106,9 @@ export async function startMcpLoopbackServer(port = 0): Promise<{
cfg,
sessionKey: requestContext.sessionKey,
messageProvider: requestContext.messageProvider,
currentChannelId: requestContext.currentChannelId,
currentThreadTs: requestContext.currentThreadTs,
currentMessageId: requestContext.currentMessageId,
accountId: requestContext.accountId,
inboundEventKind: requestContext.inboundEventKind,
sourceReplyDeliveryMode: requestContext.sourceReplyDeliveryMode,

View File

@@ -1,8 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE,
type RestartSentinelPayload,
} from "../../infra/restart-sentinel.js";
import type { RestartSentinelPayload } from "../../infra/restart-sentinel.js";
import type { RespawnSupervisor } from "../../infra/supervisor-markers.js";
import type { UpdateInstallSurface, UpdateRunResult } from "../../infra/update-runner.js";
@@ -209,10 +206,7 @@ describe("update.run sentinel deliveryContext", () => {
to: "webchat:user-123",
accountId: "default",
});
expect(payload.continuation).toEqual({
kind: "agentTurn",
message: DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE,
});
expect(payload.continuation).toBeUndefined();
});
it("omits deliveryContext when no sessionKey is provided", async () => {
@@ -238,10 +232,7 @@ describe("update.run sentinel deliveryContext", () => {
accountId: "workspace-1",
});
expect(payload.threadId).toBe("1234567890.123456");
expect(payload.continuation).toEqual({
kind: "agentTurn",
message: DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE,
});
expect(payload.continuation).toBeUndefined();
});
it("uses an explicit continuationMessage in successful update sentinels", async () => {

View File

@@ -559,7 +559,7 @@ describe("scheduleRestartSentinelWake", () => {
});
});
it("dispatches agentTurn continuation after the restart notice in the same routed thread", async () => {
it("runs agentTurn continuation internally after the restart notice without routed final delivery", async () => {
mocks.readRestartSentinel.mockResolvedValue({
payload: {
sessionKey: "agent:main:main",
@@ -595,6 +595,7 @@ describe("scheduleRestartSentinelWake", () => {
channel: "whatsapp",
accountId: "acct-2",
routeSessionKey: "agent:main:main",
replyOptions: { sourceReplyDeliveryMode: "message_tool_only" },
},
{
Body: "Reply with exactly: Yay! I did it!",
@@ -613,9 +614,16 @@ describe("scheduleRestartSentinelWake", () => {
Surface: "webchat",
OriginatingChannel: "whatsapp",
OriginatingTo: "+15550002",
ExplicitDeliverRoute: false,
MessageThreadId: "thread-42",
},
);
const deliveredContinuationReply = (
mocks.deliverOutboundPayloads.mock.calls as unknown as Array<
[{ payloads?: Array<{ text?: string }> }]
>
).some(([call]) => call.payloads?.some((payload) => payload.text === "done") === true);
expect(deliveredContinuationReply).toBe(false);
expect(mocks.requestHeartbeat).not.toHaveBeenCalled();
});
@@ -886,6 +894,7 @@ describe("scheduleRestartSentinelWake", () => {
channel: "telegram",
accountId: "default",
routeSessionKey: "agent:main:telegram:group:-1003826723328:topic:13757",
replyOptions: { sourceReplyDeliveryMode: "message_tool_only" },
},
{
Body: "continue in topic",
@@ -901,13 +910,13 @@ describe("scheduleRestartSentinelWake", () => {
ChatType: "group",
OriginatingChannel: "telegram",
OriginatingTo: "telegram:-1003826723328:topic:13757",
ExplicitDeliverRoute: true,
ExplicitDeliverRoute: false,
MessageThreadId: "13757",
},
);
});
it("preserves derived reply transport ids in continuation context", async () => {
it("preserves derived reply transport ids in internal continuation context", async () => {
mocks.getChannelPlugin.mockReturnValue({
id: "whatsapp",
meta: {
@@ -961,79 +970,12 @@ describe("scheduleRestartSentinelWake", () => {
MessageThreadId: undefined,
},
);
expectRecordFields(lastMockCallArg(mocks.deliverOutboundPayloads), {
payloads: [
{
text: "done",
replyToId: "reply:thread-42",
},
],
});
});
it("strips synthetic reply transport ids when no real reply target exists", async () => {
mocks.readRestartSentinel.mockResolvedValue({
payload: {
sessionKey: "agent:main:main",
deliveryContext: {
channel: "whatsapp",
to: "+15550002",
accountId: "acct-2",
},
ts: 123,
continuation: {
kind: "agentTurn",
message: "continue",
},
},
} as Awaited<ReturnType<typeof mocks.readRestartSentinel>>);
mocks.recordInboundSessionAndDispatchReply.mockImplementationOnce(async (params) => {
await params.deliver({
text: "done",
replyToId: "restart-sentinel:agent:main:main:agentTurn:123",
});
});
await scheduleRestartSentinelWake({ deps: {} as never });
expectRecordFields(lastMockCallArg(mocks.deliverOutboundPayloads), {
payloads: [{ text: "done" }],
});
});
it("preserves non-synthetic reply transport ids from continuation payloads", async () => {
mocks.readRestartSentinel.mockResolvedValue({
payload: {
sessionKey: "agent:main:main",
deliveryContext: {
channel: "whatsapp",
to: "+15550002",
accountId: "acct-2",
},
ts: 123,
continuation: {
kind: "agentTurn",
message: "continue",
},
},
} as Awaited<ReturnType<typeof mocks.readRestartSentinel>>);
mocks.recordInboundSessionAndDispatchReply.mockImplementationOnce(async (params) => {
await params.deliver({
text: "done",
replyToId: "provider-reply-id",
});
});
await scheduleRestartSentinelWake({ deps: {} as never });
expectRecordFields(lastMockCallArg(mocks.deliverOutboundPayloads), {
payloads: [
{
text: "done",
replyToId: "provider-reply-id",
},
],
});
const deliveredContinuationReply = (
mocks.deliverOutboundPayloads.mock.calls as unknown as Array<
[{ payloads?: Array<{ text?: string }> }]
>
).some(([call]) => call.payloads?.some((payload) => payload.text === "done") === true);
expect(deliveredContinuationReply).toBe(false);
});
it("dispatches agentTurn continuation from session delivery context when sentinel routing is empty", async () => {
@@ -1260,8 +1202,14 @@ describe("scheduleRestartSentinelWake", () => {
>
).some(([call]) => call.payloads?.some((payload) => payload.text === busyReply) === true);
expect(deliveredBusyReply).toBe(false);
const deliveredFinalReply = (
mocks.deliverOutboundPayloads.mock.calls as unknown as Array<
[{ payloads?: Array<{ text?: string }> }]
>
).some(([call]) => call.payloads?.some((payload) => payload.text === "done") === true);
expect(deliveredFinalReply).toBe(false);
expectRecordFields(lastMockCallArg(mocks.deliverOutboundPayloads), {
payloads: [{ text: "done" }],
payloads: [{ text: "restart message" }],
});
expect(mocks.logWarn.mock.calls).toEqual(
Array.from({ length: 6 }, () => [

View File

@@ -201,19 +201,6 @@ function resolveRestartContinuationRoute(params: {
};
}
function resolveRestartContinuationOutboundPayload(params: {
payload: OutboundReplyPayload;
messageId: string;
replyToId?: string;
}): OutboundReplyPayload {
if (params.payload.replyToId !== params.messageId) {
return params.payload;
}
const payload: OutboundReplyPayload = { ...params.payload };
delete payload.replyToId;
return params.replyToId ? { ...payload, replyToId: params.replyToId } : payload;
}
function isRestartContinuationBusyPayload(payload: OutboundReplyPayload): boolean {
return (
typeof payload.text === "string" && payload.text.trim() === REPLY_RUN_STILL_SHUTTING_DOWN_TEXT
@@ -313,7 +300,7 @@ async function deliverQueuedSessionDelivery(params: {
ReplyToId: route.replyToId,
OriginatingChannel: route.channel,
OriginatingTo: route.to,
ExplicitDeliverRoute: true,
ExplicitDeliverRoute: false,
MessageThreadId: route.threadId,
},
{
@@ -331,50 +318,20 @@ async function deliverQueuedSessionDelivery(params: {
ctxPayload,
recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher,
replyOptions: {
sourceReplyDeliveryMode: "message_tool_only",
},
delivery: {
preparePayload: (payload) => {
if (isRestartContinuationBusyPayload(payload)) {
throw new Error(RESTART_CONTINUATION_BUSY_RETRY_ERROR);
}
return resolveRestartContinuationOutboundPayload({
payload,
messageId,
replyToId: route.replyToId,
});
},
durable: (_payload, info) =>
info.kind === "final"
? {
to: route.to,
replyToId: route.replyToId,
threadId: route.threadId,
deps: params.deps,
}
: false,
deliver: async (payload) => {
const send = await sendDurableMessageBatch({
cfg,
channel: route.channel,
to: route.to,
accountId: route.accountId,
replyToId: route.replyToId,
threadId: route.threadId,
payloads: [payload],
session: buildOutboundSessionContext({
cfg,
sessionKey: canonicalKey,
}),
deps: params.deps,
bestEffort: false,
});
if (send.status === "failed" || send.status === "partial_failed") {
throw send.error;
}
const results = send.status === "sent" ? send.results : [];
if (results.length === 0) {
throw new Error("restart continuation delivery returned no results");
}
return payload;
},
durable: false,
// Restart continuations are internal lifecycle turns. Visible follow-up
// must go through the message tool; automatic final delivery stays off.
deliver: async () => ({ visibleReplySent: false }),
onError: (err, info) => {
dispatchError ??= err;
log.warn(`restart continuation dispatch failed during ${info.kind}: ${String(err)}`, {

View File

@@ -36,6 +36,9 @@ export function resolveGatewayScopedTools(params: {
cfg: OpenClawConfig;
sessionKey: string;
messageProvider?: string;
currentChannelId?: string;
currentThreadTs?: string;
currentMessageId?: string | number;
accountId?: string;
inboundEventKind?: InboundEventKind;
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
@@ -150,6 +153,9 @@ export function resolveGatewayScopedTools(params: {
sourceReplyDeliveryMode,
agentTo: params.agentTo,
agentThreadId: params.agentThreadId,
currentChannelId: params.currentChannelId ?? params.agentTo,
currentThreadTs: params.currentThreadTs ?? params.agentThreadId,
currentMessageId: params.currentMessageId,
senderIsOwner: params.senderIsOwner,
allowGatewaySubagentBinding: params.allowGatewaySubagentBinding,
allowMediaInvokeCommands: params.allowMediaInvokeCommands,

View File

@@ -4,7 +4,6 @@ import { describe, expect, it } from "vitest";
import { withTempDir } from "../test-helpers/temp-dir.js";
import { captureEnv } from "../test-utils/env.js";
import {
DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE,
buildRestartSuccessContinuation,
consumeRestartSentinel,
finalizeUpdateRestartSentinelRunningVersion,
@@ -271,11 +270,8 @@ describe("restart sentinel", () => {
});
describe("restart success continuation", () => {
it("builds the default agent turn for session-scoped restarts", () => {
expect(buildRestartSuccessContinuation({ sessionKey: "agent:main:main" })).toEqual({
kind: "agentTurn",
message: DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE,
});
it("does not infer an agent turn from session context alone", () => {
expect(buildRestartSuccessContinuation({ sessionKey: "agent:main:main" })).toBeNull();
});
it("keeps explicit continuation messages", () => {

View File

@@ -65,9 +65,6 @@ export type RestartSentinel = {
payload: RestartSentinelPayload;
};
export const DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE =
"The gateway restart completed successfully. Tell the user OpenClaw restarted successfully and continue any pending work.";
const SENTINEL_FILENAME = "restart-sentinel.json";
export function formatDoctorNonInteractiveHint(
@@ -170,9 +167,7 @@ export function buildRestartSuccessContinuation(params: {
if (message) {
return { kind: "agentTurn", message };
}
return params.sessionKey?.trim()
? { kind: "agentTurn", message: DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE }
: null;
return null;
}
export async function readRestartSentinel(

View File

@@ -34,6 +34,14 @@ afterEach(() => {
});
describe("security audit sandbox browser findings", () => {
beforeEach(() => {
vi.useRealTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("warns when sandbox browser containers have missing or stale hash labels", async () => {
const findings = await collectSandboxBrowserHashLabelFindings({
execDockerRawFn: async (args: string[]) => {
@@ -85,14 +93,18 @@ describe("security audit sandbox browser findings", () => {
});
it("bounds sandbox browser Docker probes that do not return", async () => {
vi.useFakeTimers();
let probeSignal: AbortSignal | undefined;
const startedAt = Date.now();
let markProbeStarted!: () => void;
const probeStarted = new Promise<void>((resolve) => {
markProbeStarted = resolve;
});
vi.useFakeTimers();
const findingsPromise = collectSandboxBrowserHashLabelFindings({
timeoutMs: 1,
timeoutMs: 250,
execDockerRawFn: async (_args, opts) => {
probeSignal = opts?.signal;
markProbeStarted();
return await new Promise((_, reject) =>
opts?.signal?.addEventListener("abort", () => reject(new Error("aborted")), {
once: true,
@@ -100,10 +112,11 @@ describe("security audit sandbox browser findings", () => {
);
},
});
await probeStarted;
await vi.advanceTimersByTimeAsync(250);
const findings = await findingsPromise;
expect(Date.now() - startedAt).toBeLessThan(1000);
expect(probeSignal?.aborted).toBe(true);
expect(findings).toEqual([
expect.objectContaining({
@@ -114,11 +127,15 @@ describe("security audit sandbox browser findings", () => {
});
it("stops probing remaining sandbox browser containers after a Docker timeout", async () => {
vi.useFakeTimers();
const calls: string[] = [];
let markHungProbeStarted!: () => void;
const hungProbeStarted = new Promise<void>((resolve) => {
markHungProbeStarted = resolve;
});
vi.useFakeTimers();
const findingsPromise = collectSandboxBrowserHashLabelFindings({
timeoutMs: 1,
timeoutMs: 250,
execDockerRawFn: async (args, opts) => {
calls.push(`${args[0] ?? ""}:${args.at(-1) ?? ""}`);
if (args[0] === "ps") {
@@ -128,6 +145,7 @@ describe("security audit sandbox browser findings", () => {
code: 0,
};
}
markHungProbeStarted();
return await new Promise((_, reject) =>
opts?.signal?.addEventListener("abort", () => reject(new Error("aborted")), {
once: true,
@@ -135,7 +153,9 @@ describe("security audit sandbox browser findings", () => {
);
},
});
await hungProbeStarted;
await vi.advanceTimersByTimeAsync(250);
const findings = await findingsPromise;
expect(calls).toEqual(["ps:{{.Names}}", "inspect:openclaw-sbx-browser-hung"]);

View File

@@ -811,7 +811,7 @@
},
{
"deferLoading": true,
"description": "Gateway restart/config/update. Before config edits, use config.schema.lookup with targeted dot path. Prefer config.patch for partial merge; config.apply only full replace. Writes hot-reload or restart as needed. Always pass human `note` for post-restart delivery. If still owe the user a reply, pass one-shot `continuationMessage`; do not write restart sentinel files directly.",
"description": "Gateway restart/config/update. Before config edits, use config.schema.lookup with targeted dot path. Prefer config.patch for partial merge; config.apply only full replace. Writes hot-reload or restart as needed. Always pass human `note` for post-restart delivery. If post-restart work must continue internally, pass one-shot `continuationMessage`; visible follow-up from that turn must use the message tool. Do not write restart sentinel files directly.",
"inputSchema": {
"properties": {
"action": {

View File

@@ -847,7 +847,7 @@
},
{
"deferLoading": true,
"description": "Gateway restart/config/update. Before config edits, use config.schema.lookup with targeted dot path. Prefer config.patch for partial merge; config.apply only full replace. Writes hot-reload or restart as needed. Always pass human `note` for post-restart delivery. If still owe the user a reply, pass one-shot `continuationMessage`; do not write restart sentinel files directly.",
"description": "Gateway restart/config/update. Before config edits, use config.schema.lookup with targeted dot path. Prefer config.patch for partial merge; config.apply only full replace. Writes hot-reload or restart as needed. Always pass human `note` for post-restart delivery. If post-restart work must continue internally, pass one-shot `continuationMessage`; visible follow-up from that turn must use the message tool. Do not write restart sentinel files directly.",
"inputSchema": {
"properties": {
"action": {

View File

@@ -811,7 +811,7 @@
},
{
"deferLoading": true,
"description": "Gateway restart/config/update. Before config edits, use config.schema.lookup with targeted dot path. Prefer config.patch for partial merge; config.apply only full replace. Writes hot-reload or restart as needed. Always pass human `note` for post-restart delivery. If still owe the user a reply, pass one-shot `continuationMessage`; do not write restart sentinel files directly.",
"description": "Gateway restart/config/update. Before config edits, use config.schema.lookup with targeted dot path. Prefer config.patch for partial merge; config.apply only full replace. Writes hot-reload or restart as needed. Always pass human `note` for post-restart delivery. If post-restart work must continue internally, pass one-shot `continuationMessage`; visible follow-up from that turn must use the message tool. Do not write restart sentinel files directly.",
"inputSchema": {
"properties": {
"action": {

View File

@@ -221,8 +221,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
"chars": 41146,
"roughTokens": 10287
"chars": 41222,
"roughTokens": 10306
},
"openClawDeveloperInstructions": {
"chars": 2988,
@@ -233,8 +233,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 6925
},
"totalWithDynamicToolsJson": {
"chars": 68848,
"roughTokens": 17212
"chars": 68924,
"roughTokens": 17231
},
"userInputText": {
"chars": 1629,

View File

@@ -221,8 +221,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
"chars": 40867,
"roughTokens": 10217
"chars": 40943,
"roughTokens": 10236
},
"openClawDeveloperInstructions": {
"chars": 1964,
@@ -233,8 +233,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 6544
},
"totalWithDynamicToolsJson": {
"chars": 67045,
"roughTokens": 16762
"chars": 67121,
"roughTokens": 16781
},
"userInputText": {
"chars": 1129,

View File

@@ -222,8 +222,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
"chars": 41962,
"roughTokens": 10491
"chars": 42038,
"roughTokens": 10510
},
"openClawDeveloperInstructions": {
"chars": 1983,
@@ -234,8 +234,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 6780
},
"totalWithDynamicToolsJson": {
"chars": 69083,
"roughTokens": 17271
"chars": 69159,
"roughTokens": 17290
},
"userInputText": {
"chars": 1367,

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildReadPermissions,
githubJson,
@@ -11,7 +11,7 @@ import {
} from "../../scripts/gh-read.js";
describe("gh-read helpers", () => {
beforeEach(() => {
afterEach(() => {
vi.useRealTimers();
});
@@ -59,19 +59,27 @@ describe("gh-read helpers", () => {
});
it("aborts stalled GitHub API fetches at the request timeout", async () => {
vi.useFakeTimers();
let signal: AbortSignal | undefined;
let markFetchStarted!: () => void;
const fetchStarted = new Promise<void>((resolve) => {
markFetchStarted = resolve;
});
vi.useFakeTimers();
const request = githubJson("/app", "token", undefined, {
timeoutMs: 5,
fetchImpl: ((_url, init) => {
signal = init?.signal ?? undefined;
markFetchStarted();
return new Promise(() => {});
}) as typeof fetch,
});
const rejection = expect(request).rejects.toThrow(/GitHub API GET \/app exceeded timeout/u);
const rejected = expect(request).rejects.toThrow(/GitHub API GET \/app exceeded timeout/u);
await fetchStarted;
await vi.advanceTimersByTimeAsync(5);
await rejected;
await rejection;
expect(signal?.aborted).toBe(true);
});
@@ -82,12 +90,13 @@ describe("gh-read helpers", () => {
timeoutMs: 5,
fetchImpl: (() => Promise.resolve(response)) as typeof fetch,
});
const rejected = expect(request).rejects.toThrow(
const rejection = expect(request).rejects.toThrow(
/GitHub API GET \/app\/installations exceeded timeout/u,
);
await vi.advanceTimersByTimeAsync(5);
await rejected;
await rejection;
});
it("bounds GitHub API error response bodies", async () => {

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { testing } from "../../scripts/label-open-issues.ts";
const labelItem = {
@@ -9,6 +9,12 @@ const labelItem = {
};
describe("label-open-issues helpers", () => {
// Timeout tests below advance fake timers explicitly so CI shard load cannot
// turn a bounded request-timeout assertion into a wall-clock wait.
afterEach(() => {
vi.useRealTimers();
});
it("classifies items from OpenAI structured response text", async () => {
const response = new Response(
JSON.stringify({
@@ -37,34 +43,49 @@ describe("label-open-issues helpers", () => {
it("aborts stalled OpenAI classification fetches at the request timeout", async () => {
let signal: AbortSignal | undefined;
let markFetchStarted!: () => void;
const fetchStarted = new Promise<void>((resolve) => {
markFetchStarted = resolve;
});
vi.useFakeTimers();
const request = testing.classifyItem(labelItem, "issue", {
apiKey: "test-key",
model: "test-model",
timeoutMs: 5,
fetchImpl: ((_url, init) => {
signal = init?.signal ?? undefined;
markFetchStarted();
return new Promise(() => {});
}) as typeof fetch,
});
await expect(request).rejects.toThrow(
const rejection = expect(request).rejects.toThrow(
/OpenAI issue label classification request exceeded timeout/u,
);
await fetchStarted;
await vi.advanceTimersByTimeAsync(5);
await rejection;
expect(signal?.aborted).toBe(true);
});
it("times out stalled OpenAI classification body reads", async () => {
const response = new Response(new ReadableStream({}), { status: 200 });
vi.useFakeTimers();
const request = testing.classifyItem(labelItem, "issue", {
apiKey: "test-key",
model: "test-model",
timeoutMs: 5,
fetchImpl: (() => Promise.resolve(response)) as typeof fetch,
});
await expect(request).rejects.toThrow(
const rejection = expect(request).rejects.toThrow(
/OpenAI issue label classification request exceeded timeout/u,
);
await vi.advanceTimersByTimeAsync(5);
await rejection;
});
it("bounds OpenAI error response bodies", async () => {