Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion backend/src/chat/openClawChatBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1333,7 +1333,14 @@ export class OpenClawChatBridge {
JSON.stringify({ runId: explicitRunId, sessionKey: storageSessionKey })
)
: 0;
if (explicitRunId && associationBytes <= MAX_BYTES_PER_EVENT) {
const isTranscriptBackedControl = envelope.canonicalEvents.some(
(canonicalEvent) => canonicalEvent.kind === "control"
);
if (
explicitRunId &&
!isTranscriptBackedControl &&
associationBytes <= MAX_BYTES_PER_EVENT
) {
this.#identity.rememberRunSession(explicitRunId, storageSessionKey);
}
if (
Expand Down
6 changes: 6 additions & 0 deletions backend/src/chat/openClawChatRetention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ export function shouldRetainRuntimeEvent(
payload: Record<string, unknown>,
canonicalEvents: OpenClawRuntimeEnvelope["canonicalEvents"]
): boolean {
// Injected control messages already live in chat.history. They must reach live
// clients, but retaining their synthetic run would displace response replay and
// could promote an interrupted response into the inject-* run.
if (canonicalEvents.some((canonicalEvent) => canonicalEvent.kind === "control")) {
return false;
}
if (event === "session.started" && !stringField(payload, "runId")) {
return false;
}
Expand Down
35 changes: 34 additions & 1 deletion backend/src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import fs from "node:fs";
import os from "node:os";
import Path from "node:path";

import { canonicalizeOpenClawHistoryPage } from "../../contracts/chat/openClawHistoryPageAdapter.ts";
import {
canonicalizeOpenClawHistoryMessageResult,
canonicalizeOpenClawHistoryPage,
} from "../../contracts/chat/openClawHistoryPageAdapter.ts";
import type { ChatRuntimeMetrics, GatewayMetrics } from "../../contracts/metrics.ts";
import type { Session } from "../../contracts/sessions.ts";
import type { DashboardSettingsResponse } from "../../contracts/settings.ts";
Expand Down Expand Up @@ -1335,6 +1338,17 @@ async function forwardRequest(
? parameters.sessionKey
: "",
});
} else if (method === "chat.message.get") {
payload = canonicalizeOpenClawHistoryMessageResult(payload, {
Comment thread
mira-2026 marked this conversation as resolved.
messageId:
typeof parameters.messageId === "string"
? parameters.messageId
: "",
sessionKey:
typeof parameters.sessionKey === "string"
? parameters.sessionKey
: "",
});
} else if (method === "sessions.list") {
normalizedSessions = normalizeGatewaySessionList(payload);
payload = { sessions: normalizedSessions };
Expand Down Expand Up @@ -1667,6 +1681,24 @@ async function sendSessionMessage(sessionKey: string, message: string): Promise<
);
}

/**
* Appends a durable control notice without creating a human chat turn, then wakes the
* owning agent through OpenClaw's system-event lane.
* @param sessionKey Session key value.
* @param message Control notice to display and deliver.
*/
async function sendSessionControlEvent(
sessionKey: string,
message: string
): Promise<void> {
await sendRequestAsync("chat.inject", { message, sessionKey }, { timeoutMs: 10_000 });
await sendRequestAsync(
"wake",
{ mode: "now", sessionKey, text: message },
{ timeoutMs: 10_000 }
);
Comment thread
mira-2026 marked this conversation as resolved.
}

/**
* Performs abort session run.
* @param sessionKey Session key value.
Expand Down Expand Up @@ -1749,6 +1781,7 @@ export default {
getMetrics,
getChatMetrics,
getGatewayWs,
sendSessionControlEvent,
sendSessionMessage,
abortSessionRun,
deleteSession,
Expand Down
2 changes: 1 addition & 1 deletion backend/src/routes/taskRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ async function notifyMira(
if (isDevelopmentExternalNotificationSuppressed()) return;
const isAutomation = currentRequestAuditContext()?.actor.type === "automation";
try {
await gateway.sendSessionMessage(
await gateway.sendSessionControlEvent(
"main",
isAutomation
? miraAutomationTaskNotificationMessage(eventType, task.id)
Expand Down
31 changes: 31 additions & 0 deletions backend/test/gatewayBehavior.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,37 @@ describe("gateway behavior", () => {
},
});
expect(chatSendRequest?.parameters).not.toHaveProperty("timeoutMs");

await gateway.sendSessionControlEvent("main", "Task progress: #388");

const controlRequests = client?.requests.filter(({ method }) =>
["chat.inject", "wake"].includes(method)
);
expect(controlRequests).toEqual([
{
method: "chat.inject",
options: { timeoutMs: 10_000 },
parameters: {
message: "Task progress: #388",
sessionKey: "main",
},
},
{
method: "wake",
options: { timeoutMs: 10_000 },
parameters: {
mode: "now",
sessionKey: "main",
text: "Task progress: #388",
},
},
]);
expect(
client?.requests.find(
({ method, parameters }) =>
method === "chat.send" && parameters.message === "Task progress: #388"
)
).toBeUndefined();
});

it("rehydrates run associations before reconnect events resume", async () => {
Expand Down
76 changes: 76 additions & 0 deletions backend/test/openClawChatBridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3543,6 +3543,82 @@ describe("OpenClaw chat bridge", () => {
]);
});

it("broadcasts injected controls without retaining or promoting synthetic runs", () => {
const store = new MemorySnapshotStore();
const interruptedRunId = "provider-before-control";
const resumedRunId = "provider-after-control";
const bridge = new OpenClawChatBridge(store);

bridge.recordEvent(
"agent",
{
data: { delta: "before restart" },
runId: interruptedRunId,
sessionKey: MAIN,
stream: "thinking",
},
[]
);
bridge.markGatewayDisconnected();
expect(bridge.flush()).toBe(true);

const restarted = new OpenClawChatBridge(store);
const control = restarted.recordEvent(
"chat",
{
message: {
content: "Task progress: #389",
model: "gateway-injected",
provider: "openclaw",
role: "assistant",
stopReason: "stop",
},
runId: "inject-control-1",
sessionKey: MAIN,
state: "final",
},
[]
);

expect(control.runtimeRunAliases).toBeUndefined();
expect(control.canonicalEvents).toEqual([
expect.objectContaining({
kind: "control",
lifecycle: "completed",
}),
]);
expect(control.canonicalEvents[0]?.runId).toBeUndefined();
expect(restarted.snapshot(MAIN)).toMatchObject({
completed: false,
events: [
expect.objectContaining({
payload: expect.objectContaining({ runId: interruptedRunId }),
}),
],
});

const resumed = restarted.recordEvent(
"agent",
{
data: {
item: { kind: "preamble", progressText: "after control" },
phase: "update",
stream: "item",
},
runId: resumedRunId,
sessionKey: MAIN,
},
[]
);

expect(resumed.runtimeRunAliases).toEqual([interruptedRunId]);
expect(
restarted
.snapshot(MAIN)
.events.map((event) => (event.payload as { runId?: string }).runId)
).toEqual([resumedRunId, resumedRunId]);
});

it("repairs one active provider run after an abrupt Dashboard restart", () => {
const store = new MemorySnapshotStore();
const providerRunId = "provider-before-dashboard-crash";
Expand Down
6 changes: 3 additions & 3 deletions backend/test/routeAndServiceBehavior.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -941,10 +941,10 @@ describe("backend route and service behavior", () => {
const gatewayModule = await import("../src/gateway.ts");
const gateway = gatewayModule.default;
const originalRequest = gateway.request;
const originalSendSessionMessage = gateway.sendSessionMessage;
const originalSendSessionControlEvent = gateway.sendSessionControlEvent;
cleanupCallbacks.push(() => {
gateway.request = originalRequest;
gateway.sendSessionMessage = originalSendSessionMessage;
gateway.sendSessionControlEvent = originalSendSessionControlEvent;
});
const taskNotifications: string[] = [];
gateway.request = () =>
Expand All @@ -961,7 +961,7 @@ describe("backend route and service behavior", () => {
},
],
}));
gateway.sendSessionMessage = (_sessionKey, message) => {
gateway.sendSessionControlEvent = (_sessionKey, message) => {
return Promise.try(() => {
taskNotifications.push(message);
});
Expand Down
3 changes: 3 additions & 0 deletions backend/test/serviceBehavior.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6457,6 +6457,9 @@ fi
expect(gateway.sendSessionMessage("agent:main:main", "hello")).rejects.toThrow(
"Gateway not connected"
);
expect(
gateway.sendSessionControlEvent("agent:main:main", "hello")
).rejects.toThrow("Gateway not connected");
expect(gateway.abortSessionRun("agent:main:main")).rejects.toThrow(
"Gateway not connected"
);
Expand Down
16 changes: 10 additions & 6 deletions contracts/chat/openClawAdapterValues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,8 @@ export function isToolResultItem(data: Record<string, unknown>): boolean {
].includes(itemType(data));
}

export function isThinkingItem(data: Record<string, unknown>): boolean {
const markers = itemStrings(data, [
function itemMarkerText(data: Record<string, unknown>): string {
return itemStrings(data, [
"itemId",
"itemKind",
"kind",
Expand All @@ -197,10 +197,14 @@ export function isThinkingItem(data: Record<string, unknown>): boolean {
])
.join(" ")
.toLowerCase();
return (
markers.includes("preamble") ||
/\b(reasoning|reason|thinking|analysis)\b/u.test(markers)
);
}

export function isPreambleItem(data: Record<string, unknown>): boolean {
return itemMarkerText(data).includes("preamble");
}

export function isThinkingItem(data: Record<string, unknown>): boolean {
return /\b(reasoning|reason|thinking|analysis)\b/u.test(itemMarkerText(data));
}

export function normalizeAssistant(value: unknown, runId?: string): CanonicalChatMessage {
Expand Down
24 changes: 23 additions & 1 deletion contracts/chat/openClawHistoryNormalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,21 @@ export interface RawOpenClawHistoryMessage {
stopReason?: unknown;
}

function historyMetadata(message: RawOpenClawHistoryMessage): Record<string, unknown> {
return message.__openclaw &&
typeof message.__openclaw === "object" &&
!Array.isArray(message.__openclaw)
? (message.__openclaw as Record<string, unknown>)
: {};
}

function isInjectedControlMessage(message: RawOpenClawHistoryMessage): boolean {
return (
stringValue(message.provider)?.toLowerCase() === "openclaw" &&
stringValue(message.model)?.toLowerCase() === "gateway-injected"
);
}

function normalizedIsFinal(message: RawOpenClawHistoryMessage): true | undefined {
const role = typeof message.role === "string" ? message.role.toLowerCase() : "";
if (
Expand Down Expand Up @@ -410,6 +425,7 @@ function stripGeneratedImagePlaceholder(
export function normalizeOpenClawHistoryMessage(
message: RawOpenClawHistoryMessage
): CanonicalChatMessage {
const isControl = isInjectedControlMessage(message);
const content = message.content ?? message.text ?? "";
const primaryText = normalizeCanonicalChatText(primaryContent(content));
const canonicalMedia = canonicalizeCanonicalChatMedia(content);
Expand All @@ -427,11 +443,17 @@ export function normalizeOpenClawHistoryMessage(
images,
attachments
);
let role = typeof message.role === "string" ? message.role : "unknown";
if (isControl) {
role = "system";
}
return {
role: typeof message.role === "string" ? message.role : "unknown",
role,
content: canonicalMedia.content,
controlId: isControl ? stringValue(historyMetadata(message).id) : undefined,
text,
images,
intent: isControl ? "control" : undefined,
attachments,
isFinal: normalizedIsFinal(message),
isToolUse: normalizedIsToolUse(message),
Expand Down
Loading