* 'main' of https://github.com/openclaw/openclaw:
  docs(changelog): credit subagent announce fix
  fix(slack): keep newest rich progress lines
  fix(agents): preserve full subagent announce output
  ci: preserve Windows Testbox phone-home POST
  fix(agents): suppress mid-turn continuation prompts
This commit is contained in:
Vincent Koc
2026-05-03 22:38:35 -07:00
8 changed files with 317 additions and 10 deletions

View File

@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
- Channels/streaming: add unified `streaming.mode: "progress"` drafts with auto single-word status labels and shared progress configuration across Discord, Telegram, Matrix, Slack, and Microsoft Teams.
- Slack/streaming: add `streaming.progress.render: "rich"` for Block Kit progress drafts backed by structured progress line data.
- Slack/streaming: keep the newest rich progress lines when Block Kit limits trim long progress drafts. Thanks @vincentkoc.
- Channels/streaming: cap progress-draft tool lines by default so edited progress boxes avoid jumpy reflow from long wrapped lines.
- Agents/verbose: use compact explain-mode tool summaries for `/verbose` and progress drafts by default, with `agents.defaults.toolProgressDetail: "raw"` and per-agent overrides for debugging raw command/detail output.
- Agents/commands: add `/steer <message>` for queue-independent steering of the active current-session run without starting a new turn when the session is idle. (#76934)
@@ -50,6 +51,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Agents/Pi: suppress persistence for synthetic mid-turn overflow continuation prompts, so transcript-retry recovery does not write the "continue from transcript" prompt as a new user turn. Thanks @vincentkoc.
- Exec approvals: detect `env -S` split-string command-carrier risks when `-S`/`-s` is combined with other env short options, so approval explanations do not miss split payloads hidden behind `env -iS...`. Thanks @vincentkoc.
- Voice Call: mark realtime calls completed when the realtime provider closes normally, so Twilio/OpenAI/Google realtime stop events do not leave active call records behind. Thanks @vincentkoc.
- Exec approvals: treat POSIX `exec` as a command carrier for inline eval, shell-wrapper, and eval/source detection, so approval explanations and command-risk checks do not miss payloads hidden behind `exec`. Thanks @vincentkoc.
@@ -259,6 +261,7 @@ Docs: https://docs.openclaw.ai
- Plugins/install: keep managed npm-root security scans from treating earlier plugin `openclaw` peer links as failures, so one external plugin install cannot poison later official npm installs. Thanks @vincentkoc.
- Memory LanceDB: allow installed-but-unconfigured plugin metadata to load so onboarding and setup flows can prompt for embedding config instead of failing the plugin registry first. Thanks @vincentkoc.
- CLI/plugins: keep `plugins enable` and `plugins disable` from creating unconfigured channel config sections, so channel plugins with required setup fields no longer fail validation during lifecycle probes. Thanks @vincentkoc.
- Agents/subagents: detect prefix-only completion announce replies and fall back to the captured child result so requester chats no longer lose most of long sub-agent reports silently. Fixes #76412. Thanks @inxaos and @davemorin.
- Doctor/config: set `messages.groupChat.visibleReplies: "message_tool"` during compatibility repair for configured-channel configs that omit a visible-reply policy, so upgrades can persist the intended tool-only group/channel reply default. Thanks @kagura-agent.
- Agents/sessions: keep delayed `sessions_send` A2A replies alive after soft wait-window timeouts, while preserving terminal run timeouts and avoiding stale target replies in requester sessions. Fixes #76443. Thanks @ryswork1993 and @vincentkoc.
- TUI/Control UI: fix `/think` command showing only base thinking levels when the active session uses a different model from the default, so provider-specific levels like DeepSeek V4 Pro's `xhigh` and `max` are now visible and selectable. Fixes #76482. Thanks @amknight.

View File

@@ -83,7 +83,8 @@ requester chat when the run finishes.
</Accordion>
<Accordion title="Manual-spawn delivery resilience">
- OpenClaw tries direct `agent` delivery first with a stable idempotency key.
- If direct delivery fails, it falls back to queue routing.
- If the requester-agent completion turn fails, produces no visible output, or returns an obviously incomplete prefix of the captured child result, OpenClaw falls back to direct completion delivery from the captured child result.
- If direct delivery cannot be used, it falls back to queue routing.
- If queue routing is still not available, the announce is retried with a short exponential backoff before final give-up.
- Completion delivery keeps the resolved requester route: thread-bound or conversation-bound completion routes win when available; if the completion origin only provides a channel, OpenClaw fills the missing target/account from the requester session's resolved route (`lastChannel` / `lastTo` / `lastAccountId`) so direct delivery still works.

View File

@@ -64,7 +64,7 @@ describe("buildSlackProgressDraftBlocks", () => {
});
});
it("caps rich progress blocks to Slack's maximum while leaving caller text fallback independent", () => {
it("keeps newest rich progress lines when capping Slack blocks", () => {
const blocksWithLabel = buildSlackProgressDraftBlocks({
label: "Shelling...",
lines: Array.from({ length: 60 }, (_value, index) => progressLine(index)),
@@ -74,18 +74,26 @@ describe("buildSlackProgressDraftBlocks", () => {
type: "section",
text: { text: "*Shelling...*" },
});
expect(blocksWithLabel?.[1]).toMatchObject({
type: "section",
fields: [{ text: "🛠️ *Exec 11*" }, { text: "run 11" }],
});
expect(blocksWithLabel?.at(-1)).toMatchObject({
type: "section",
fields: [{ text: "🛠️ *Exec 48*" }, { text: "run 48" }],
fields: [{ text: "🛠️ *Exec 59*" }, { text: "run 59" }],
});
const blocksWithoutLabel = buildSlackProgressDraftBlocks({
lines: Array.from({ length: 60 }, (_value, index) => progressLine(index)),
});
expect(blocksWithoutLabel).toHaveLength(50);
expect(blocksWithoutLabel?.[0]).toMatchObject({
type: "section",
fields: [{ text: "🛠️ *Exec 10*" }, { text: "run 10" }],
});
expect(blocksWithoutLabel?.at(-1)).toMatchObject({
type: "section",
fields: [{ text: "🛠️ *Exec 49*" }, { text: "run 49" }],
fields: [{ text: "🛠️ *Exec 59*" }, { text: "run 59" }],
});
});
});

View File

@@ -55,7 +55,7 @@ export function buildSlackProgressDraftBlocks(params: {
});
}
const availableLineBlocks = Math.max(0, SLACK_MAX_BLOCKS - blocks.length);
for (const line of params.lines.slice(0, availableLineBlocks)) {
for (const line of params.lines.slice(-availableLineBlocks)) {
blocks.push({
type: "section",
fields: [field(lineTitle(line)), field(lineDetail(line))],

View File

@@ -359,6 +359,7 @@ describe("overflow compaction in run loop", () => {
2,
expect.objectContaining({
prompt: expect.stringContaining("Continue from the current transcript"),
suppressNextUserMessagePersistence: true,
}),
);
expect(mockedRunEmbeddedAttempt).not.toHaveBeenNthCalledWith(
@@ -433,6 +434,7 @@ describe("overflow compaction in run loop", () => {
2,
expect.objectContaining({
prompt: expect.stringContaining("Continue from the current transcript"),
suppressNextUserMessagePersistence: true,
}),
);
expect(mockedRunEmbeddedAttempt).not.toHaveBeenNthCalledWith(

View File

@@ -817,6 +817,10 @@ export async function runEmbeddedPiAgent(
}
params.onUserMessagePersisted?.(message);
};
const continueFromCurrentTranscript = () => {
nextAttemptPromptOverride = MID_TURN_PRECHECK_CONTINUATION_PROMPT;
suppressNextUserMessagePersistence = true;
};
const maybeEscalateRateLimitProfileFallback = (params: {
failoverProvider: string;
failoverModel: string;
@@ -1327,7 +1331,7 @@ export async function runEmbeddedPiAgent(
(retryingFromTranscript ? "retrying from current transcript" : "retrying prompt"),
);
if (retryingFromTranscript) {
nextAttemptPromptOverride = MID_TURN_PRECHECK_CONTINUATION_PROMPT;
continueFromCurrentTranscript();
}
continue;
}
@@ -1512,7 +1516,7 @@ export async function runEmbeddedPiAgent(
`context overflow persisted after in-attempt compaction (attempt ${overflowCompactionAttempts}/${MAX_OVERFLOW_COMPACTION_ATTEMPTS}); retrying prompt without additional compaction for ${provider}/${modelId}`,
);
if (preflightRecovery?.source === "mid-turn") {
nextAttemptPromptOverride = MID_TURN_PRECHECK_CONTINUATION_PROMPT;
continueFromCurrentTranscript();
}
continue;
}
@@ -1645,7 +1649,7 @@ export async function runEmbeddedPiAgent(
autoCompactionCount += 1;
log.info(`auto-compaction succeeded for ${provider}/${modelId}; retrying prompt`);
if (preflightRecovery?.source === "mid-turn") {
nextAttemptPromptOverride = MID_TURN_PRECHECK_CONTINUATION_PROMPT;
continueFromCurrentTranscript();
} else if (
params.currentMessageId !== undefined &&
params.currentMessageId === lastPersistedCurrentMessageId
@@ -1696,7 +1700,7 @@ export async function runEmbeddedPiAgent(
`[context-overflow-recovery] Truncated ${truncResult.truncatedCount} tool result(s); retrying prompt`,
);
if (preflightRecovery?.source === "mid-turn") {
nextAttemptPromptOverride = MID_TURN_PRECHECK_CONTINUATION_PROMPT;
continueFromCurrentTranscript();
}
continue;
}

View File

@@ -44,6 +44,13 @@ function createSendMessageMock() {
})) as unknown as typeof runtimeSendMessage;
}
const longChildCompletionOutput = [
"34/34 tests pass, clean build. Now docker repro:",
"Root cause: the requester's announce delivery accepted a prefix-only assistant payload as delivered.",
"PR: https://github.com/openclaw/openclaw/pull/12345",
"Verification: pnpm test src/agents/subagent-announce-delivery.test.ts passed with the regression enabled.",
].join("\n");
async function deliverSlackThreadAnnouncement(params: {
callGateway: typeof runtimeCallGateway;
isActive: boolean;
@@ -583,6 +590,216 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
expect(sendMessage).not.toHaveBeenCalled();
});
it("uses direct fallback when announce-agent delivery returns only a child-result prefix", async () => {
const callGateway = createGatewayMock({
result: {
payloads: [{ text: "34/34 tests pass, clean build. Now docker repro:" }],
},
});
const sendMessage = createSendMessageMock();
const result = await deliverSlackThreadAnnouncement({
callGateway,
sendMessage,
sessionId: "requester-session-4",
isActive: false,
expectsCompletionMessage: true,
directIdempotencyKey: "announce-thread-fallback-prefix",
internalEvents: [
{
type: "task_completion",
source: "subagent",
childSessionKey: "agent:worker:subagent:child",
childSessionId: "child-session-id",
announceType: "subagent task",
taskLabel: "thread completion smoke",
status: "ok",
statusLabel: "completed successfully",
result: longChildCompletionOutput,
replyInstruction: "Summarize the result.",
},
],
});
expect(result).toEqual(
expect.objectContaining({
delivered: true,
path: "direct-thread-fallback",
}),
);
expect(sendMessage).toHaveBeenCalledWith(
expect.objectContaining({
content: longChildCompletionOutput,
idempotencyKey: "announce-thread-fallback-prefix",
}),
);
});
it("uses direct fallback when announce-agent delivery returns a word-boundary child-result prefix", async () => {
const callGateway = createGatewayMock({
result: {
payloads: [{ text: "34/34 tests pass, clean build. Now docker repro" }],
},
});
const sendMessage = createSendMessageMock();
const result = await deliverSlackThreadAnnouncement({
callGateway,
sendMessage,
sessionId: "requester-session-4",
isActive: false,
expectsCompletionMessage: true,
directIdempotencyKey: "announce-thread-fallback-word-prefix",
internalEvents: [
{
type: "task_completion",
source: "subagent",
childSessionKey: "agent:worker:subagent:child",
childSessionId: "child-session-id",
announceType: "subagent task",
taskLabel: "thread completion smoke",
status: "ok",
statusLabel: "completed successfully",
result: longChildCompletionOutput,
replyInstruction: "Summarize the result.",
},
],
});
expect(result).toEqual(
expect.objectContaining({
delivered: true,
path: "direct-thread-fallback",
}),
);
expect(sendMessage).toHaveBeenCalledWith(
expect.objectContaining({
content: longChildCompletionOutput,
idempotencyKey: "announce-thread-fallback-word-prefix",
}),
);
});
it("uses direct fallback when announce-agent delivery returns a mid-word child-result prefix", async () => {
const callGateway = createGatewayMock({
result: {
payloads: [{ text: "34/34 tests pass, clean build. Now dock" }],
},
});
const sendMessage = createSendMessageMock();
const result = await deliverSlackThreadAnnouncement({
callGateway,
sendMessage,
sessionId: "requester-session-4",
isActive: false,
expectsCompletionMessage: true,
directIdempotencyKey: "announce-thread-fallback-midword-prefix",
internalEvents: [
{
type: "task_completion",
source: "subagent",
childSessionKey: "agent:worker:subagent:child",
childSessionId: "child-session-id",
announceType: "subagent task",
taskLabel: "thread completion smoke",
status: "ok",
statusLabel: "completed successfully",
result: longChildCompletionOutput,
replyInstruction: "Summarize the result.",
},
],
});
expect(result).toEqual(
expect.objectContaining({
delivered: true,
path: "direct-thread-fallback",
}),
);
expect(sendMessage).toHaveBeenCalledWith(
expect.objectContaining({
content: longChildCompletionOutput,
idempotencyKey: "announce-thread-fallback-midword-prefix",
}),
);
});
it("keeps concise requester rewrites primary even when child output is long", async () => {
const callGateway = createGatewayMock({
result: {
payloads: [{ text: "Tests passed and the PR is ready for review." }],
},
});
const sendMessage = createSendMessageMock();
const result = await deliverSlackThreadAnnouncement({
callGateway,
sendMessage,
sessionId: "requester-session-4",
isActive: false,
expectsCompletionMessage: true,
directIdempotencyKey: "announce-thread-rewrite-primary",
internalEvents: [
{
type: "task_completion",
source: "subagent",
childSessionKey: "agent:worker:subagent:child",
childSessionId: "child-session-id",
announceType: "subagent task",
taskLabel: "thread completion smoke",
status: "ok",
statusLabel: "completed successfully",
result: longChildCompletionOutput,
replyInstruction: "Summarize the result.",
},
],
});
expect(result).toEqual(
expect.objectContaining({
delivered: true,
path: "direct",
}),
);
expect(sendMessage).not.toHaveBeenCalled();
});
it("keeps copied complete-sentence requester summaries primary", async () => {
const callGateway = createGatewayMock({
result: {
payloads: [{ text: "34/34 tests pass, clean build." }],
},
});
const sendMessage = createSendMessageMock();
const result = await deliverSlackThreadAnnouncement({
callGateway,
sendMessage,
sessionId: "requester-session-4",
isActive: false,
expectsCompletionMessage: true,
directIdempotencyKey: "announce-thread-copied-summary-primary",
internalEvents: [
{
type: "task_completion",
source: "subagent",
childSessionKey: "agent:worker:subagent:child",
childSessionId: "child-session-id",
announceType: "subagent task",
taskLabel: "thread completion smoke",
status: "ok",
statusLabel: "completed successfully",
result: longChildCompletionOutput,
replyInstruction: "Summarize the result.",
},
],
});
expect(result).toEqual(
expect.objectContaining({
delivered: true,
path: "direct",
}),
);
expect(sendMessage).not.toHaveBeenCalled();
});
it("uses a direct thread fallback when announce-agent delivery fails", async () => {
const callGateway = vi.fn(async () => {
throw new Error("UNAVAILABLE: gateway lost final output");

View File

@@ -54,6 +54,9 @@ import type { SpawnSubagentMode } from "./subagent-spawn.types.js";
const DEFAULT_SUBAGENT_ANNOUNCE_TIMEOUT_MS = 120_000;
const MAX_TIMER_SAFE_TIMEOUT_MS = 2_147_000_000;
const MIN_COMPLETION_INTEGRITY_RESULT_LENGTH = 120;
const MIN_COMPLETION_INTEGRITY_PREFIX_LENGTH = 24;
const MAX_COMPLETION_INTEGRITY_PREFIX_RATIO = 0.8;
type SubagentAnnounceDeliveryDeps = {
callGateway: typeof callGateway;
@@ -565,6 +568,75 @@ function hasVisibleGatewayAgentPayload(response: unknown): boolean {
);
}
function collectVisibleGatewayAgentText(response: unknown): string {
const result = getGatewayAgentResult(response);
const payloads = result?.payloads;
if (!Array.isArray(payloads)) {
return "";
}
return payloads
.flatMap((payload) => {
if (!payload || typeof payload !== "object") {
return [];
}
const text = (payload as { text?: unknown; isError?: unknown; isReasoning?: unknown }).text;
if (typeof text !== "string") {
return [];
}
if (
(payload as { isError?: unknown; isReasoning?: unknown }).isError === true ||
(payload as { isError?: unknown; isReasoning?: unknown }).isReasoning === true
) {
return [];
}
const trimmed = text.trim();
return trimmed ? [trimmed] : [];
})
.join("\n")
.trim();
}
function normalizeCompletionIntegrityText(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
function hasCompleteCompletionSummaryBoundary(value: string): boolean {
const trimmed = value.replace(/[\s"')\]]+$/g, "");
if (!trimmed) {
return false;
}
return /[.!?]$/.test(trimmed);
}
function hasIncompleteCompletionPrefix(response: unknown, completionFallbackText: string): boolean {
const result = getGatewayAgentResult(response);
if (!result || hasMessagingToolDeliveryEvidence(result)) {
return false;
}
const expected = normalizeCompletionIntegrityText(completionFallbackText);
if (expected.length < MIN_COMPLETION_INTEGRITY_RESULT_LENGTH) {
return false;
}
const visible = normalizeCompletionIntegrityText(collectVisibleGatewayAgentText(response));
if (
visible.length < MIN_COMPLETION_INTEGRITY_PREFIX_LENGTH ||
visible.length >= expected.length * MAX_COMPLETION_INTEGRITY_PREFIX_RATIO
) {
return false;
}
return expected.startsWith(visible) && !hasCompleteCompletionSummaryBoundary(visible);
}
function shouldSendCompletionFallback(response: unknown, completionFallbackText: string): boolean {
if (!completionFallbackText) {
return false;
}
if (!hasVisibleGatewayAgentPayload(response)) {
return true;
}
return hasIncompleteCompletionPrefix(response, completionFallbackText);
}
async function sendCompletionFallback(params: {
cfg: OpenClawConfig;
channel?: string;
@@ -840,7 +912,7 @@ async function sendSubagentAnnounceDirectly(params: {
throw err;
}
if (completionFallbackText && !hasVisibleGatewayAgentPayload(directAnnounceResponse)) {
if (shouldSendCompletionFallback(directAnnounceResponse, completionFallbackText)) {
const didFallback = await sendCompletionFallback({
cfg,
channel: deliveryTarget.channel,