diff --git a/src/auto-reply/reply/commands-tasks.test.ts b/src/auto-reply/reply/commands-tasks.test.ts index 05675645aa3..af6d0c710f6 100644 --- a/src/auto-reply/reply/commands-tasks.test.ts +++ b/src/auto-reply/reply/commands-tasks.test.ts @@ -102,6 +102,32 @@ describe("buildTasksReply", () => { expect(reply.text).not.toContain("Internal task completion event"); }); + it("sanitizes inline internal runtime fences from visible task titles", async () => { + createRunningTaskRun({ + runtime: "cli", + requesterSessionKey: "agent:main:main", + childSessionKey: "agent:main:main", + runId: "run-tasks-inline-fence", + task: [ + "[Mon 2026-04-06 02:42 GMT+1] <<>>", + "OpenClaw runtime context (internal):", + "This context is runtime-generated, not user-authored. Keep internal details private.", + ].join("\n"), + progressSummary: "done", + }); + completeTaskRunByRunId({ + runId: "run-tasks-inline-fence", + endedAt: Date.now(), + terminalSummary: "Finished.", + }); + + const reply = await buildTasksReplyForTest(); + + expect(reply.text).toContain("[Mon 2026-04-06 02:42 GMT+1]"); + expect(reply.text).not.toContain("BEGIN_OPENCLAW_INTERNAL_CONTEXT"); + expect(reply.text).not.toContain("OpenClaw runtime context (internal):"); + }); + it("hides stale completed tasks from the task board", async () => { createQueuedTaskRun({ runtime: "cron", diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index 1381fb8bbb8..85b3a1c31b2 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -797,6 +797,47 @@ describe("gateway agent handler", () => { ); }); + it("does not create task rows for inter-session completion wakes", async () => { + primeMainAgentRun(); + mocks.agentCommand.mockClear(); + + await invokeAgent( + { + message: [ + "[Mon 2026-04-06 02:42 GMT+1] <<>>", + "OpenClaw runtime context (internal):", + "This context is runtime-generated, not user-authored. Keep internal details private.", + ].join("\n"), + sessionKey: "agent:main:main", + internalEvents: [ + { + type: "task_completion", + source: "music_generation", + childSessionKey: "music:task-123", + childSessionId: "task-123", + announceType: "music generation task", + taskLabel: "compose a loop", + status: "ok", + statusLabel: "completed successfully", + result: "MEDIA:/tmp/song.mp3", + replyInstruction: "Reply in your normal assistant voice now.", + }, + ], + inputProvenance: { + kind: "inter_session", + sourceSessionKey: "music_generate:task-123", + sourceChannel: "internal", + sourceTool: "music_generate", + }, + idempotencyKey: "music-generation-event-inter-session", + }, + { reqId: "music-generation-event-inter-session" }, + ); + + await waitForAssertion(() => expect(mocks.agentCommand).toHaveBeenCalled()); + expect(findTaskByRunId("music-generation-event-inter-session")).toBeUndefined(); + }); + it("only forwards workspaceDir for spawned sessions with stored workspace inheritance", async () => { primeMainAgentRun(); mockMainSessionEntry({ diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 5ec9fd8e285..30d8b1b75a0 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -192,7 +192,10 @@ function dispatchAgentRunFromGateway(params: { respond: GatewayRequestHandlerOptions["respond"]; context: GatewayRequestHandlerOptions["context"]; }) { - if (params.ingressOpts.sessionKey?.trim()) { + const inputProvenance = normalizeInputProvenance(params.ingressOpts.inputProvenance); + const shouldTrackTask = + params.ingressOpts.sessionKey?.trim() && inputProvenance?.kind !== "inter_session"; + if (shouldTrackTask) { try { createRunningTaskRun({ runtime: "cli", diff --git a/src/tasks/task-status.ts b/src/tasks/task-status.ts index a09f336df65..7596eb859b6 100644 --- a/src/tasks/task-status.ts +++ b/src/tasks/task-status.ts @@ -1,3 +1,7 @@ +import { + INTERNAL_RUNTIME_CONTEXT_BEGIN, + INTERNAL_RUNTIME_CONTEXT_END, +} from "../agents/internal-runtime-context.js"; import { sanitizeUserFacingText } from "../agents/pi-embedded-helpers/errors.js"; import { truncateUtf16Safe } from "../utils.js"; import type { TaskRecord } from "./task-registry.types.js"; @@ -42,9 +46,34 @@ function truncateTaskStatusText(value: string, maxChars: number): string { return `${truncateUtf16Safe(trimmed, Math.max(0, maxChars - 1)).trimEnd()}…`; } +function stripInlineLeakedInternalContext(value: string): string { + const beginIndex = value.indexOf(INTERNAL_RUNTIME_CONTEXT_BEGIN); + if ( + beginIndex !== -1 && + (value.includes(INTERNAL_RUNTIME_CONTEXT_END) || + value.includes("OpenClaw runtime context (internal):") || + value.includes("[Internal task completion event]")) + ) { + return value.slice(0, beginIndex); + } + const legacyHeaderIndex = value.indexOf("OpenClaw runtime context (internal):"); + if ( + legacyHeaderIndex !== -1 && + (value.includes("Keep internal details private.") || + value.includes("[Internal task completion event]")) + ) { + return value.slice(0, legacyHeaderIndex); + } + return value; +} + function sanitizeTaskStatusValue(value: unknown, errorContext: boolean): unknown { if (typeof value === "string") { - const sanitized = sanitizeUserFacingText(value, { errorContext }).replace(/\s+/g, " ").trim(); + const sanitized = sanitizeUserFacingText(stripInlineLeakedInternalContext(value), { + errorContext, + }) + .replace(/\s+/g, " ") + .trim(); return sanitized || undefined; } if (Array.isArray(value)) {