From 584fa3215c19e5ac0e100b8acb240a71c17a1617 Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Fri, 29 May 2026 19:06:54 -0700 Subject: [PATCH] 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 --- src/agents/cli-runner.spawn.test.ts | 6 ++ src/agents/cli-runner.ts | 3 + src/agents/cli-runner/prepare.test.ts | 12 +++ src/agents/cli-runner/prepare.ts | 7 ++ src/agents/cli-runner/types.ts | 3 + src/agents/tools/gateway-tool.test.ts | 18 ++-- src/agents/tools/gateway-tool.ts | 2 +- .../reply/agent-runner-execution.ts | 15 +++ .../reply/agent-runner-utils.test.ts | 59 ++++++++++ src/auto-reply/reply/agent-runner-utils.ts | 7 +- .../reply/commands-session-restart.test.ts | 14 +-- src/auto-reply/reply/followup-runner.test.ts | 12 +++ src/auto-reply/reply/followup-runner.ts | 19 ++++ .../reply/get-reply-run.media-only.test.ts | 2 + src/auto-reply/reply/get-reply-run.ts | 1 + src/auto-reply/reply/queue/types.ts | 2 + src/gateway/mcp-http.loopback-runtime.ts | 3 + src/gateway/mcp-http.request.ts | 6 ++ src/gateway/mcp-http.runtime.ts | 15 +++ src/gateway/mcp-http.test.ts | 18 ++++ src/gateway/mcp-http.ts | 3 + src/gateway/server-methods/update.test.ts | 15 +-- src/gateway/server-restart-sentinel.test.ts | 102 +++++------------- src/gateway/server-restart-sentinel.ts | 61 ++--------- src/gateway/tool-resolution.ts | 6 ++ src/infra/restart-sentinel.test.ts | 8 +- src/infra/restart-sentinel.ts | 7 +- src/security/audit-sandbox-browser.test.ts | 32 ++++-- .../codex-dynamic-tools.discord-group.json | 2 +- .../codex-dynamic-tools.heartbeat-turn.json | 2 +- .../codex-dynamic-tools.telegram-direct.json | 2 +- .../discord-group-codex-message-tool.md | 8 +- .../telegram-direct-codex-message-tool.md | 8 +- .../telegram-heartbeat-codex-tool.md | 8 +- test/scripts/gh-read.test.ts | 25 +++-- test/scripts/label-open-issues.test.ts | 31 +++++- 36 files changed, 333 insertions(+), 211 deletions(-) diff --git a/src/agents/cli-runner.spawn.test.ts b/src/agents/cli-runner.spawn.test.ts index 9ad0ef372f6..99bf015a4db 100644 --- a/src/agents/cli-runner.spawn.test.ts +++ b/src/agents/cli-runner.spawn.test.ts @@ -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"); }); diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts index 2a6d16be93b..a903f94d8e6 100644 --- a/src/agents/cli-runner.ts +++ b/src/agents/cli-runner.ts @@ -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, }; } diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index 4bd04c509d8..df480c584f3 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -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", }); diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index 9ffdee3e289..b077da4149d 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -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, diff --git a/src/agents/cli-runner/types.ts b/src/agents/cli-runner/types.ts index 0999bbda546..6b19a609181 100644 --- a/src/agents/cli-runner/types.ts +++ b/src/agents/cli-runner/types.ts @@ -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; diff --git a/src/agents/tools/gateway-tool.test.ts b/src/agents/tools/gateway-tool.test.ts index e117a02bef0..d2ad326d005 100644 --- a/src/agents/tools/gateway-tool.test.ts +++ b/src/agents/tools/gateway-tool.test.ts @@ -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 () => { diff --git a/src/agents/tools/gateway-tool.ts b/src/agents/tools/gateway-tool.ts index 9f74788c8bd..8e9b03a6f84 100644 --- a/src/agents/tools/gateway-tool.ts +++ b/src/agents/tools/gateway-tool.ts @@ -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; diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 802da1bd66d..37890a5f8ae 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -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, diff --git a/src/auto-reply/reply/agent-runner-utils.test.ts b/src/auto-reply/reply/agent-runner-utils.test.ts index 8b159ba3112..d971cfc083d 100644 --- a/src/auto-reply/reply/agent-runner-utils.test.ts +++ b/src/auto-reply/reply/agent-runner-utils.test.ts @@ -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"); + }); }); diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index 47d34483f34..6b3e83fd0b7 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -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, diff --git a/src/auto-reply/reply/commands-session-restart.test.ts b/src/auto-reply/reply/commands-session-restart.test.ts index 9e683b8d486..990854790a6 100644 --- a/src/auto-reply/reply/commands-session-restart.test.ts +++ b/src/auto-reply/reply/commands-session-restart.test.ts @@ -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); } diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index 89ffc482983..8ee4bf383a6 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -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", diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index d8c15671e3f..6a16f05b0b4 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -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, diff --git a/src/auto-reply/reply/get-reply-run.media-only.test.ts b/src/auto-reply/reply/get-reply-run.media-only.test.ts index 7bb9c5acc33..f371228181a 100644 --- a/src/auto-reply/reply/get-reply-run.media-only.test.ts +++ b/src/auto-reply/reply/get-reply-run.media-only.test.ts @@ -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 () => { diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index f81bf7449a9..254011290c0 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -1216,6 +1216,7 @@ export async function runPreparedReply( originatingTo: ctx.OriginatingTo, originatingAccountId: sessionCtx.AccountId, originatingThreadId, + originatingReplyToId: sessionCtx.ReplyToId, originatingChatType: ctx.ChatType, run: { agentId, diff --git a/src/auto-reply/reply/queue/types.ts b/src/auto-reply/reply/queue/types.ts index aad78ad1e47..04bb1ef3e19 100644 --- a/src/auto-reply/reply/queue/types.ts +++ b/src/auto-reply/reply/queue/types.ts @@ -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: { diff --git a/src/gateway/mcp-http.loopback-runtime.ts b/src/gateway/mcp-http.loopback-runtime.ts index f76a6ef79cf..3d7cf908c60 100644 --- a/src/gateway/mcp-http.loopback-runtime.ts +++ b/src/gateway/mcp-http.loopback-runtime.ts @@ -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}", }, diff --git a/src/gateway/mcp-http.request.ts b/src/gateway/mcp-http.request.ts index 6fc5cd1671f..d9b3e18cdb1 100644 --- a/src/gateway/mcp-http.request.ts +++ b/src/gateway/mcp-http.request.ts @@ -30,6 +30,9 @@ function logMcpLoopbackHttp(step: string, details: Record): 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( diff --git a/src/gateway/mcp-http.runtime.ts b/src/gateway/mcp-http.runtime.ts index 2d4d0644845..c72395d97cf 100644 --- a/src/gateway/mcp-http.runtime.ts +++ b/src/gateway/mcp-http.runtime.ts @@ -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, diff --git a/src/gateway/mcp-http.test.ts b/src/gateway/mcp-http.test.ts index 179d2dbf662..8dfa66c6859 100644 --- a/src/gateway/mcp-http.test.ts +++ b/src/gateway/mcp-http.test.ts @@ -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}", ); diff --git a/src/gateway/mcp-http.ts b/src/gateway/mcp-http.ts index 73c1a04db79..4e982c036ac 100644 --- a/src/gateway/mcp-http.ts +++ b/src/gateway/mcp-http.ts @@ -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, diff --git a/src/gateway/server-methods/update.test.ts b/src/gateway/server-methods/update.test.ts index 1c776fc932d..f90807cba88 100644 --- a/src/gateway/server-methods/update.test.ts +++ b/src/gateway/server-methods/update.test.ts @@ -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 () => { diff --git a/src/gateway/server-restart-sentinel.test.ts b/src/gateway/server-restart-sentinel.test.ts index e594d0c3b64..24631fea076 100644 --- a/src/gateway/server-restart-sentinel.test.ts +++ b/src/gateway/server-restart-sentinel.test.ts @@ -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>); - 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>); - 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 }, () => [ diff --git a/src/gateway/server-restart-sentinel.ts b/src/gateway/server-restart-sentinel.ts index 1714c87feff..5cf7fc4796a 100644 --- a/src/gateway/server-restart-sentinel.ts +++ b/src/gateway/server-restart-sentinel.ts @@ -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)}`, { diff --git a/src/gateway/tool-resolution.ts b/src/gateway/tool-resolution.ts index f4903a25d43..1068e471767 100644 --- a/src/gateway/tool-resolution.ts +++ b/src/gateway/tool-resolution.ts @@ -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, diff --git a/src/infra/restart-sentinel.test.ts b/src/infra/restart-sentinel.test.ts index dea86d0bae8..e8d05dc0e0d 100644 --- a/src/infra/restart-sentinel.test.ts +++ b/src/infra/restart-sentinel.test.ts @@ -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", () => { diff --git a/src/infra/restart-sentinel.ts b/src/infra/restart-sentinel.ts index 0a91fc63dca..ed215558c4c 100644 --- a/src/infra/restart-sentinel.ts +++ b/src/infra/restart-sentinel.ts @@ -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( diff --git a/src/security/audit-sandbox-browser.test.ts b/src/security/audit-sandbox-browser.test.ts index 7492d42d99e..a33cf28afc6 100644 --- a/src/security/audit-sandbox-browser.test.ts +++ b/src/security/audit-sandbox-browser.test.ts @@ -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((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((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"]); diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json index 3d883992650..df569c8d8cd 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json @@ -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": { diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json index f59bcf23b07..d17d70cbdc6 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json @@ -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": { diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json index 40b5c69e4bf..6a44f57135d 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json @@ -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": { diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md index d5c687f76ca..5e12d697440 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md @@ -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, diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md index 7d65f2bbc25..a478ac14258 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md @@ -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, diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md index 200fbbff254..f58c19e3863 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md @@ -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, diff --git a/test/scripts/gh-read.test.ts b/test/scripts/gh-read.test.ts index cd549773fc5..3e9c32d9c0c 100644 --- a/test/scripts/gh-read.test.ts +++ b/test/scripts/gh-read.test.ts @@ -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((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 () => { diff --git a/test/scripts/label-open-issues.test.ts b/test/scripts/label-open-issues.test.ts index 993ef89e6ca..76c9fe52866 100644 --- a/test/scripts/label-open-issues.test.ts +++ b/test/scripts/label-open-issues.test.ts @@ -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((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 () => {