Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions backend/src/development/developmentGatewayPolicy.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const RECENT_MFA_EXEMPT_GATEWAY_METHODS = new Set([
"chat.history",
"chat.message.get",
"chat.runtimeSnapshot",
"models.list",
"sessions.list",
Expand All @@ -19,6 +20,7 @@ const DEVELOPMENT_ALLOWED_GATEWAY_METHODS = new Set([
const DEVELOPMENT_GATEWAY_PROXY_METHODS = new Set([
"chat.abort",
"chat.history",
"chat.message.get",
"chat.send",
"config.get",
"cron.list",
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
15 changes: 15 additions & 0 deletions backend/test/bunNativeServerBehavior.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,21 @@ describe("Bun-native dashboard backend", () => {
isOk: false,
type: "response",
});
const fullMessageRead = await nextSocketMessage(passwordSocket, {
id: "password-full-message-read",
method: "chat.message.get",
params: {
messageId: "history-message-1",
sessionKey: "agent:main:main",
},
type: "req",
});
expect(fullMessageRead).toMatchObject({
error: "Gateway not connected",
id: "password-full-message-read",
isOk: false,
type: "response",
});
passwordSocket.close();

const staleSocket = await connectDashboardSocket(state.staleSessionToken);
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
Loading