Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
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
72 changes: 71 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 @@ -746,6 +749,38 @@ async function hydrateOmittedChatHistoryImages(
return history;
}

/**
* Rehydrates omitted image blocks in one `chat.message.get` response.
* @param payload Raw full-message response.
* @param requestedSessionKey Requested session key.
* @returns Response with transcript-backed image data when available.
*/
async function hydrateOmittedChatMessageImages(
payload: unknown,
requestedSessionKey?: string
): Promise<unknown> {
const result = asRecord(payload);
if (!result || !asRecord(result.message)) {
return payload;
}
const hydratedHistory = asRecord(
await hydrateOmittedChatHistoryImages(
{
messages: [result.message],
sessionId:
typeof result.sessionId === "string" ? result.sessionId : undefined,
sessionKey:
typeof result.sessionKey === "string"
? result.sessionKey
: requestedSessionKey,
},
requestedSessionKey
)
) as ChatHistoryPayload | undefined;
const hydratedMessage = hydratedHistory?.messages?.[0];
return hydratedMessage ? { ...result, message: hydratedMessage } : payload;
}

function isCurrentGatewayClient(expectedClient: OpenClawGatewayClientInstance): boolean {
return gatewayState.client === expectedClient;
}
Expand Down Expand Up @@ -1335,6 +1370,22 @@ async function forwardRequest(
? parameters.sessionKey
: "",
});
} else if (method === "chat.message.get") {
const requestedSessionKey =
typeof parameters.sessionKey === "string"
? parameters.sessionKey
: undefined;
payload = await hydrateOmittedChatMessageImages(
payload,
requestedSessionKey
);
payload = canonicalizeOpenClawHistoryMessageResult(payload, {
Comment thread
mira-2026 marked this conversation as resolved.
messageId:
typeof parameters.messageId === "string"
? parameters.messageId
: "",
sessionKey: requestedSessionKey ?? "",
});
} else if (method === "sessions.list") {
normalizedSessions = normalizeGatewaySessionList(payload);
payload = { sessions: normalizedSessions };
Expand Down Expand Up @@ -1667,6 +1718,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 +1818,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
136 changes: 136 additions & 0 deletions backend/test/gatewayBehavior.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,28 @@ class FakeOpenClawGatewayClient implements OpenClawGatewayClientInstance {
}
return response;
}
if (method === "chat.message.get") {
if (requestParameters.messageId === "history-message-missing") {
return { ok: false, unavailableReason: "not_found" };
}
return {
message: {
__openclaw: {
id: "history-message-1",
seq: 1,
},
content: [
{ text: "see image", type: "text" },
{ source: { omitted: true }, type: "image" },
],
model: "gpt-test",
provider: "openai",
role: "assistant",
timestamp: 1_782_345_600_000,
},
ok: true,
};
}
if (method === "demo.fail") {
throw new Error("gateway rejected");
}
Expand Down Expand Up @@ -620,6 +642,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 Expand Up @@ -1454,6 +1507,89 @@ describe("gateway behavior", () => {
},
});

socket.emitMessage({
id: "history-full-message",
method: "chat.message.get",
params: {
messageId: "history-message-1",
sessionKey: "agent:main:main",
},
type: "request",
});
await waitFor(() =>
socket.sent.some((raw) => raw.includes('"id":"history-full-message"'))
);
expect(
socket.sent
.map(
(raw) =>
JSON.parse(raw) as {
id?: string;
isOk?: boolean;
payload?: unknown;
}
)
.find((message) => message.id === "history-full-message")
).toMatchObject({
isOk: true,
payload: {
message: {
id: "openclaw-history:agent%3Amain%3Amain:history-message-1",
message: {
role: "assistant",
text: "see image",
images: [
expect.objectContaining({
data: "base64-image",
mimeType: "image/png",
}),
],
},
messageId: "history-message-1",
provider: {
eventName: "chat.message.get",
format: "openclaw-history",
},
sessionKey: "agent:main:main",
truncated: false,
},
ok: true,
schemaVersion: 1,
},
});

socket.emitMessage({
id: "history-full-message-missing",
method: "chat.message.get",
params: {
messageId: "history-message-missing",
sessionKey: "agent:main:main",
},
type: "request",
});
await waitFor(() =>
socket.sent.some((raw) => raw.includes('"id":"history-full-message-missing"'))
);
expect(
socket.sent
.map(
(raw) =>
JSON.parse(raw) as {
id?: string;
isOk?: boolean;
payload?: unknown;
}
)
.find((message) => message.id === "history-full-message-missing")
).toMatchObject({
isOk: true,
payload: {
ok: false,
schemaVersion: 1,
unavailableReason: "not_found",
},
});

const { sessionRoutes } = await import("../src/routes/sessionRoutes.ts");
const filteredSessions = sessionRoutes["/api/sessions/list"].GET(
new Request("https://test.local/api/sessions/list?type=MAIN&model=gpt-test")
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
Loading